diff --git a/eslint.config.js b/eslint.config.js index 10a64ff..ab679c5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,12 +26,7 @@ export default tseslint.config( 'no-restricted-imports': [ 'warn', { - patterns: [ - { - group: ['**/components/FolderTree/FolderTree*', '**/FolderTree/FolderTree*'], - message: 'FolderTree is deprecated — use FormGeneratorTable with groupingConfig instead.', - }, - ], + patterns: [], }, ], }, diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index e251006..7e2c67a 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -280,3 +280,121 @@ export function collectGroupItemIds( // - previewFile: Requires flexible responseType (json or blob) // These are kept in the hooks for now due to their special requirements +// ============================================================================ +// FOLDER TYPES & API FUNCTIONS +// ============================================================================ + +export interface FolderInfo { + id: string; + name: string; + parentId: string | null; + mandateId: string; + featureInstanceId: string; + scope: string; + neutralize: boolean; + contextOrphan?: boolean; + sysCreatedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; +} + +export async function getFolderTree( + request: ApiRequestFunction, + owner: 'me' | 'shared' = 'me', +): Promise { + const data = await request({ + url: '/api/files/folders/tree', + method: 'get', + params: { owner }, + }); + return Array.isArray(data) ? data : []; +} + +export async function createFolder( + request: ApiRequestFunction, + name: string, + parentId?: string | null, +): Promise { + return await request({ + url: '/api/files/folders', + method: 'post', + data: { name, parentId: parentId ?? null }, + }); +} + +export async function renameFolder( + request: ApiRequestFunction, + folderId: string, + name: string, +): Promise { + return await request({ + url: `/api/files/folders/${folderId}`, + method: 'patch', + data: { name }, + }); +} + +export async function moveFolder( + request: ApiRequestFunction, + folderId: string, + parentId: string | null, +): Promise { + return await request({ + url: `/api/files/folders/${folderId}/move`, + method: 'post', + data: { parentId }, + }); +} + +export async function deleteFolderCascade( + request: ApiRequestFunction, + folderId: string, +): Promise<{ deletedFolders: number; deletedFiles: number }> { + return await request({ + url: `/api/files/folders/${folderId}`, + method: 'delete', + params: { cascade: true }, + }); +} + +export async function patchFolderScope( + request: ApiRequestFunction, + folderId: string, + scope: string, + cascadeToFiles: boolean = false, +): Promise<{ folderId: string; scope: string; filesUpdated: number }> { + return await request({ + url: `/api/files/folders/${folderId}/scope`, + method: 'patch', + data: { scope, cascadeToFiles }, + }); +} + +export async function patchFolderNeutralize( + request: ApiRequestFunction, + folderId: string, + neutralize: boolean, +): Promise<{ folderId: string; neutralize: boolean; filesUpdated: number }> { + return await request({ + url: `/api/files/folders/${folderId}/neutralize`, + method: 'patch', + data: { neutralize }, + }); +} + +export async function moveFiles( + request: ApiRequestFunction, + fileIds: string[], + targetFolderId: string | null, +): Promise { + await Promise.all( + fileIds.map((fileId) => + request({ + url: `/api/files/${fileId}`, + method: 'put', + data: { folderId: targetFolderId }, + }), + ), + ); +} + diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css deleted file mode 100644 index 5d929f5..0000000 --- a/src/components/FolderTree/FolderTree.module.css +++ /dev/null @@ -1,197 +0,0 @@ -.folderTree { - font-size: 0.875rem; - user-select: none; -} - -.treeNode { - display: flex; - align-items: center; - padding: 2px 4px; - cursor: pointer; - border-radius: 4px; - gap: 2px; - min-height: 26px; - position: relative; -} - -.treeNode:hover { - background: var(--color-bg-hover, rgba(0, 0, 0, 0.04)); -} - -.treeNode.selected { - background: var(--color-bg-selected, rgba(25, 118, 210, 0.08)); - font-weight: 600; -} - -.treeNode.multiSelected { - background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14)); - box-shadow: inset 3px 0 0 var(--color-primary, #F25843); -} - -.treeNode.multiSelected:hover { - background: var(--color-bg-multi-selected-hover, rgba(25, 118, 210, 0.20)); -} - -.treeNode.dropTarget { - background: var(--color-bg-drop, rgba(25, 118, 210, 0.15)); - outline: 2px dashed var(--color-primary, #F25843); - outline-offset: -2px; -} - -.treeNode.dragging { - opacity: 0.5; -} - -/* Visueller Hint für Custom-Drag-Sources (z. B. Workflow-Files): - * pulst dezent beim Hover, um zu signalisieren "hier kann ich woanders hingezogen werden". */ -@keyframes _customDragPulse { - 0%, 100% { box-shadow: inset 0 0 0 0 transparent; } - 50% { box-shadow: inset 2px 0 0 0 var(--color-primary, #F25843); } -} -.treeNode.hasCustomDrag:hover { - animation: _customDragPulse 1.6s ease-in-out infinite; -} - -.chevron { - width: 12px; - height: 12px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: transform 0.15s ease; - color: var(--color-text-secondary, #666); - font-size: 8px; -} - -.chevron.expanded { - transform: rotate(90deg); -} - -.chevron.empty { - visibility: hidden; -} - -.folderIcon { - flex-shrink: 0; - color: var(--color-text-secondary, #888); - font-size: 13px; -} - -.folderName { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.renameInput { - flex: 1; - border: 1px solid var(--color-primary, #F25843); - border-radius: 3px; - padding: 1px 4px; - font-size: inherit; - font-family: inherit; - outline: none; - min-width: 0; -} - -/* Right zone: contains dynamic on-hover actions + always-visible stable trio. - * The stable trio (chat / scope / neutralize) sits at the right edge in a - * fixed slot order so icons never jump. Dynamic actions appear on hover - * to the left of the trio without displacing it. */ -.rightZone { - display: flex; - align-items: center; - gap: 4px; - margin-left: auto; - flex-shrink: 0; -} - -.actions { - display: none; - gap: 2px; - flex-shrink: 0; -} - -.treeNode:hover .actions { - display: flex; -} - -.stableActions { - display: flex; - gap: 2px; - flex-shrink: 0; - align-items: center; -} - -.iconSlot { - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 20px; - flex-shrink: 0; -} - -.iconSlot.placeholder { - visibility: hidden; -} - -.actionBtn { - background: none; - border: none; - cursor: pointer; - padding: 2px 4px; - border-radius: 3px; - color: var(--color-text-secondary, #888); - font-size: 12px; - line-height: 1; - display: flex; - align-items: center; -} - -.actionBtn:hover { - background: var(--color-bg-hover, rgba(0, 0, 0, 0.08)); - color: var(--color-text-primary, #333); -} - -.actionBtn.danger:hover { - color: var(--color-error, #d32f2f); -} - -.children { - padding-left: 10px; -} - -.rootLabel { - font-weight: 600; - color: var(--color-text-primary, #333); -} - -/* File nodes inside the tree */ -.fileNode { - cursor: pointer; -} - -.fileNode:hover { - background: var(--color-bg-hover, rgba(0, 0, 0, 0.04)); -} - -.fileIcon { - flex-shrink: 0; - font-size: 11px; -} - -.fileSize { - font-size: 10px; - color: var(--color-text-secondary, #999); - flex-shrink: 0; -} - -.rootActions { - display: flex; - gap: 2px; - margin-left: auto; - flex-shrink: 0; -} diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx deleted file mode 100644 index 52b1096..0000000 --- a/src/components/FolderTree/FolderTree.tsx +++ /dev/null @@ -1,1170 +0,0 @@ -/** - * FolderTree – Shared recursive folder/file tree component. - * - * Used on the Files page and in the Workspace chat. - * Supports: - * - Alphabetical sorting per level (folders first, then files) - * - Multi-selection (CTRL+click, SHIFT+click) with visual highlight - * - Batch drag-and-drop for selected items - * - Inline CRUD icons for folders - * - showFiles mode renders files inline under their parent folder - * - Drag-out: sets application/tree-items on dataTransfer for external drop targets - */ - -import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; -import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaSyncAlt, FaDownload } from 'react-icons/fa'; -import { usePrompt, type PromptOptions } from '../../hooks/usePrompt'; -import styles from './FolderTree.module.css'; - -import { useLanguage } from '../../providers/language/LanguageContext'; -import { - type FileAction, - type FileActionContext, - type FileActionTarget, - type UdbSurface, - resolveActionLabel, -} from './actions/types'; -import { useFileActions, runAction, type ResolvedActions } from './actions/registry'; -import { useViewMode } from './actions/useViewMode'; -import { usePointerLongPress } from './actions/usePointerLongPress'; -import { FileActionContextMenu } from './actions/FileActionContextMenu'; -import { FileActionBottomSheet } from './actions/FileActionBottomSheet'; - -/* ── Public types ──────────────────────────────────────────────────────── */ - -export interface FolderNode { - id: string; - name: string; - parentId: string | null; - fileCount?: number; - children?: FolderNode[]; - isProtected?: boolean; - isReadonly?: boolean; - icon?: string; - neutralize?: boolean; - scope?: string; -} - -export interface FileNode { - id: string; - fileName: string; - mimeType?: string; - fileSize?: number; - folderId?: string | null; - scope?: string; - neutralize?: boolean; - sysCreatedBy?: string; - isReadonly?: boolean; -} - -export interface TreeItem { - id: string; - type: 'file' | 'folder'; - name: string; -} - -export interface FolderTreeProps { - folders: FolderNode[]; - files?: FileNode[]; - showFiles?: boolean; - selectedFolderId: string | null; - onSelect: (folderId: string | null) => void; - onFileSelect?: (fileId: string) => void; - selectedItemIds?: Set; - onSelectionChange?: (selectedIds: Set) => void; - expandedIds?: Set; - onToggleExpand?: (id: string) => void; - onRefresh?: () => void; - onCreateFolder?: (name: string, parentId: string | null) => Promise; - onRenameFolder?: (folderId: string, newName: string) => Promise; - onDeleteFolder?: (folderId: string) => Promise; - onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; - onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; - onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; - onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; - onRenameFile?: (fileId: string, newName: string) => Promise; - onDeleteFile?: (fileId: string) => Promise; - onDeleteFiles?: (fileIds: string[]) => Promise; - onDeleteFolders?: (folderIds: string[]) => Promise; - onDownloadFolder?: (folderId: string, folderName: string) => Promise; - onScopeChange?: (fileId: string, newScope: string) => void; - onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; - onFolderScopeChange?: (folderId: string, newScope: string) => void; - onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; - onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; - /** Optionale Custom-Aktionen (Plugin-Slot, siehe `actions/types.ts`). - * Built-in Aktionen funktionieren auch ohne dieses Prop unverändert. */ - customActions?: FileAction[]; - /** Aufruf-Surface (z. B. ``'graphEditor'``) — wird in Predicates der Custom-Actions gespiegelt. */ - udbContext?: UdbSurface; -} - -/* ── Helpers ───────────────────────────────────────────────────────────── */ - -function _buildTree(folders: FolderNode[]): FolderNode[] { - const map = new Map(); - const roots: FolderNode[] = []; - for (const f of folders) map.set(f.id, { ...f, children: [] }); - for (const f of folders) { - const node = map.get(f.id)!; - if (f.parentId && map.has(f.parentId)) { - map.get(f.parentId)!.children!.push(node); - } else { - roots.push(node); - } - } - const _sortLevel = (nodes: FolderNode[]) => { - nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); - for (const n of nodes) { - if (n.children && n.children.length > 0) _sortLevel(n.children); - } - }; - _sortLevel(roots); - return roots; -} - -function _groupFilesByFolder(files: FileNode[]): Map { - const map = new Map(); - for (const f of files) { - const key = f.folderId || ''; - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(f); - } - for (const [, arr] of map) { - arr.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' })); - } - return map; -} - -function _computeFlatList( - tree: FolderNode[], - expandedIds: Set, - showFiles: boolean, - filesByFolder: Map, -): TreeItem[] { - const result: TreeItem[] = []; - const _walk = (nodes: FolderNode[]) => { - for (const node of nodes) { - result.push({ id: node.id, type: 'folder', name: node.name }); - if (expandedIds.has(node.id)) { - if (node.children) _walk(node.children); - if (showFiles) { - for (const f of (filesByFolder.get(node.id) || [])) { - result.push({ id: f.id, type: 'file', name: f.fileName }); - } - } - } - } - }; - _walk(tree); - if (showFiles) { - for (const f of (filesByFolder.get('') || [])) { - result.push({ id: f.id, type: 'file', name: f.fileName }); - } - } - return result; -} - -function _matchesShortcut(e: KeyboardEvent, shortcut: string): boolean { - const parts = shortcut.toLowerCase().split('+').map(p => p.trim()); - const wantMod = parts.includes('mod'); - const wantShift = parts.includes('shift'); - const wantAlt = parts.includes('alt'); - const wantCtrl = parts.includes('ctrl') && !wantMod; - const key = parts.find(p => !['mod', 'shift', 'alt', 'ctrl'].includes(p)); - if (!key) return false; - const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); - const modOk = wantMod ? (isMac ? e.metaKey : e.ctrlKey) : true; - const ctrlOk = wantCtrl ? e.ctrlKey : (wantMod ? true : !e.ctrlKey); - const shiftOk = wantShift === e.shiftKey; - const altOk = wantAlt === e.altKey; - const keyOk = e.key.toLowerCase() === key || e.code.toLowerCase() === `key${key}`; - return modOk && ctrlOk && shiftOk && altOk && keyOk; -} - -function _windowConfirm(_title: string, body: string): boolean { - if (typeof window === 'undefined') return true; - return window.confirm(body); -} - -function _fileIcon(mime?: string): string { - if (!mime) return '\uD83D\uDCC4'; - if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; - if (mime.includes('pdf')) return '\uD83D\uDCD5'; - if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8'; - if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA'; - if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9'; - if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6'; - if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD'; - if (mime.startsWith('audio/')) return '\uD83C\uDFB5'; - if (mime.startsWith('video/')) return '\uD83C\uDFA5'; - return '\uD83D\uDCC4'; -} - -/* ── Selection context threaded through the tree ──────────────────────── */ - -const _SCOPE_ICONS: Record = { - personal: '\uD83D\uDC64', - featureInstance: '\uD83D\uDC65', - mandate: '\uD83C\uDFE2', - global: '\uD83C\uDF10', -}; - -const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate']; - -interface SelectionCtx { - selectedItemIds: Set; - selectedFileIds: string[]; - selectedFolderIds: string[]; - onItemClick: (id: string, type: 'file' | 'folder', e: React.MouseEvent) => void; - onItemDragStart: (e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => void; - onRenameFile?: (fileId: string, newName: string) => Promise; - onDeleteFile?: (fileId: string) => Promise; - onDeleteFiles?: (fileIds: string[]) => Promise; - onDeleteFolders?: (folderIds: string[]) => Promise; - onScopeChange?: (fileId: string, newScope: string) => void; - onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; - onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; - /** Action-System Pipeline. Wenn vorhanden, rendert das FileItem zusätzlich - * Right-Click-Menu, Long-Press-Sheet und Custom-Inline-Icons. */ - actions?: { - actionCtx: FileActionContext; - /** Liefert pro Target die nach Kanal sortierten/gefilterten Aktionen. */ - resolveFor: (target: FileActionTarget) => ResolvedActions; - /** Öffnet das Right-Click-Menu am angegebenen Viewport-Punkt. */ - openMenu: (anchor: { x: number; y: number }, target: FileActionTarget, title?: string) => void; - /** Öffnet das Bottom-Sheet (Mobile Long-Press). */ - openSheet: (target: FileActionTarget, title?: string) => void; - /** Custom-Drag-MIME-Types, die zusätzlich ans dataTransfer gehängt werden. */ - applyDragPayload: (e: React.DragEvent, target: FileActionTarget) => void; - }; - /** Inline-Rename-Trigger des FolderTree (für die Built-in `core.rename`-Action). */ - registerInlineRename: (fileId: string, fn: () => void) => void; -} - -/* ── Stable trio (chat | scope | neutralize) ────────────────────────────── - * Always rendered in this order, always at the right edge of the row. - * Each slot has a fixed width so missing actions render an invisible - * placeholder — icons never jump position between rows. */ - -interface StableTrioProps { - scope?: string; - neutralize?: boolean; - scopeLabels: Record; - onChat?: () => void; - onScopeChange?: (newScope: string) => void; - onNeutralizeToggle?: (newValue: boolean) => void; - chatTitle: string; -} - -function _StableTrio({ - scope, neutralize, - scopeLabels, - onChat, onScopeChange, onNeutralizeToggle, - chatTitle, -}: StableTrioProps) { - const { t } = useLanguage(); - const _cycleScope = (current: string | undefined) => { - const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal'); - return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length]; - }; - - return ( - - {/* Slot 1: Chat */} - {onChat ? ( - - ) : ( - - )} - {/* Slot 2: Scope */} - {onScopeChange && scope != null ? ( - - ) : ( - - )} - {/* Slot 3: Neutralize */} - {onNeutralizeToggle ? ( - - ) : ( - - )} - - ); -} - -/* ── File node (leaf) ─────────────────────────────────────────────────── */ - -function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { - const { t } = useLanguage(); - const scopeLabels = useMemo((): Record => ({ - personal: t('Persönlich'), - featureInstance: t('Instanz'), - mandate: t('Mandant'), - global: t('Global'), - }), [t]); - const [dragging, setDragging] = useState(false); - const [renaming, setRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(''); - const isSelected = sel.selectedItemIds.has(file.id); - const multiSelected = sel.selectedItemIds.size > 1; - - const _beginRename = useCallback(() => { - setRenameValue(file.fileName); - setRenaming(true); - }, [file.fileName]); - useEffect(() => { - sel.registerInlineRename(file.id, _beginRename); - }, [file.id, _beginRename, sel]); - - const _buildActionTarget = useCallback((): FileActionTarget => { - return { files: [file], folders: [] }; - }, [file]); - - const _onContextMenu = useCallback((e: React.MouseEvent) => { - if (!sel.actions) return; - e.preventDefault(); - e.stopPropagation(); - sel.actions.openMenu({ x: e.clientX, y: e.clientY }, _buildActionTarget(), file.fileName); - }, [sel.actions, _buildActionTarget, file.fileName]); - - const _longPressHandlers = usePointerLongPress( - useCallback(() => { - if (!sel.actions) return; - sel.actions.openSheet(_buildActionTarget(), file.fileName); - }, [sel.actions, _buildActionTarget, file.fileName]), - ); - - const inlineCustomActions = useMemo(() => { - if (!sel.actions) return []; - return sel.actions.resolveFor(_buildActionTarget()).inline; - }, [sel.actions, _buildActionTarget]); - - const _handleRename = useCallback(async () => { - const trimmed = renameValue.trim(); - if (trimmed && trimmed !== file.fileName && sel.onRenameFile) { - await sel.onRenameFile(file.id, trimmed); - } - setRenaming(false); - }, [renameValue, file.id, file.fileName, sel.onRenameFile]); - - const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); - if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) { - await sel.onDeleteFiles(sel.selectedFileIds); - } - }, [sel]); - - const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); - if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) { - await sel.onDeleteFolders(sel.selectedFolderIds); - } - }, [sel]); - - const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); - if (sel.onDeleteFile) await sel.onDeleteFile(file.id); - }, [file.id, sel]); - - return ( -
0 ? styles.hasCustomDrag : '', - ].filter(Boolean).join(' ')} - onClick={(e) => sel.onItemClick(file.id, 'file', e)} - onContextMenu={_onContextMenu} - {..._longPressHandlers} - draggable - onDragStart={(e) => { - sel.onItemDragStart(e, file.id, 'file', file.fileName); - if (sel.actions) sel.actions.applyDragPayload(e, _buildActionTarget()); - setDragging(true); - }} - onDragEnd={() => setDragging(false)} - > - - {_fileIcon(file.mimeType)} - {renaming ? ( - setRenameValue(e.target.value)} - onBlur={_handleRename} - onKeyDown={(e) => { - if (e.key === 'Enter') _handleRename(); - if (e.key === 'Escape') setRenaming(false); - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {file.fileName} - )} - {!renaming && ( - - {file.fileSize != null && ( - - {(file.fileSize / 1024).toFixed(0)}K - - )} - - {!multiSelected && inlineCustomActions.slice(0, 3).map((a) => { - const Icon = a.icon; - return ( - - ); - })} - {sel.onRenameFile && !multiSelected && ( - - )} - {multiSelected && isSelected ? ( - <> - {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( - - )} - {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( - - )} - - ) : ( - (sel.onDeleteFile || sel.onDeleteFiles) && ( - - ) - )} - - <_StableTrio - scope={file.scope} - neutralize={file.neutralize} - scopeLabels={scopeLabels} - onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined} - onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined} - onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined} - chatTitle={t('In Chat senden')} - /> - - )} -
- ); -} - -/* ── Tree node (folder) ───────────────────────────────────────────────── */ - -interface TreeNodeProps { - node: FolderNode; - depth: number; - selectedFolderId: string | null; - expandedIds: Set; - showFiles: boolean; - filesByFolder: Map; - sel: SelectionCtx; - promptFolderName: (message: string, options?: PromptOptions) => Promise; - onToggle: (id: string) => void; - onSelect: (id: string | null) => void; - onCreateFolder?: (name: string, parentId: string | null) => Promise; - onRenameFolder?: (folderId: string, newName: string) => Promise; - onDeleteFolder?: (folderId: string) => Promise; - onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; - onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; - onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; - onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; - onDownloadFolder?: (folderId: string, folderName: string) => Promise; - onFolderScopeChange?: (folderId: string, newScope: string) => void; - onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; -} - -function _TreeNode({ - node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel, - promptFolderName, - onToggle, onSelect, - onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, - onDownloadFolder, onFolderScopeChange, onFolderNeutralizeToggle, -}: TreeNodeProps) { - const { t } = useLanguage(); - const scopeLabels = useMemo((): Record => ({ - personal: t('Persönlich'), - featureInstance: t('Instanz'), - mandate: t('Mandant'), - global: t('Global'), - }), [t]); - const [renaming, setRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(node.name); - const [dropOver, setDropOver] = useState(false); - const [dragging, setDragging] = useState(false); - const inputRef = useRef(null); - const isExpanded = expandedIds.has(node.id); - const isNavSelected = selectedFolderId === node.id; - const isMultiSelected = sel.selectedItemIds.has(node.id); - const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : []; - const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0 || (node.fileCount ?? 0) > 0; - - useEffect(() => { - if (renaming && inputRef.current) inputRef.current.focus(); - }, [renaming]); - - const _handleRename = useCallback(async () => { - const trimmed = renameValue.trim(); - if (trimmed && trimmed !== node.name && onRenameFolder) { - await onRenameFolder(node.id, trimmed); - } - setRenaming(false); - }, [renameValue, node.id, node.name, onRenameFolder]); - - const _handleAdd = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); - if (!onCreateFolder) return; - const name = await promptFolderName(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') }); - if (name?.trim()) { - await onCreateFolder(name.trim(), node.id); - if (!expandedIds.has(node.id)) onToggle(node.id); - } - }, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName, t]); - - const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); - if (onDeleteFolder) await onDeleteFolder(node.id); - }, [onDeleteFolder, node.id]); - - const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); - if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) { - await sel.onDeleteFolders(sel.selectedFolderIds); - } - }, [sel]); - - const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); - if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) { - await sel.onDeleteFiles(sel.selectedFileIds); - } - }, [sel]); - - const _handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - setDropOver(true); - }, []); - - const _handleDragLeave = useCallback(() => setDropOver(false), []); - - const _handleDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - setDropOver(false); - - const treeItemsJson = e.dataTransfer.getData('application/tree-items'); - if (treeItemsJson) { - const items: TreeItem[] = JSON.parse(treeItemsJson); - const fileIds = items.filter(i => i.type === 'file').map(i => i.id); - const folderIds = items.filter(i => i.type === 'folder' && i.id !== node.id).map(i => i.id); - - if (folderIds.length > 0 && onMoveFolders) { - await onMoveFolders(folderIds, node.id); - } else if (onMoveFolder) { - for (const fId of folderIds) await onMoveFolder(fId, node.id); - } - if (fileIds.length > 0 && onMoveFiles) { - await onMoveFiles(fileIds, node.id); - } else if (fileIds.length > 0 && onMoveFile) { - for (const fId of fileIds) await onMoveFile(fId, node.id); - } - return; - } - - const folderId = e.dataTransfer.getData('application/folder-id'); - const fileIdsJson = e.dataTransfer.getData('application/file-ids'); - const fileId = e.dataTransfer.getData('application/file-id'); - if (folderId && folderId !== node.id && onMoveFolder) { - await onMoveFolder(folderId, node.id); - } else if (fileIdsJson && onMoveFiles) { - await onMoveFiles(JSON.parse(fileIdsJson), node.id); - } else if (fileId && onMoveFile) { - await onMoveFile(fileId, node.id); - } - }, [node.id, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]); - - const nodeClasses = [ - styles.treeNode, - isNavSelected && !isMultiSelected ? styles.selected : '', - isMultiSelected ? styles.multiSelected : '', - dropOver ? styles.dropTarget : '', - dragging ? styles.dragging : '', - ].filter(Boolean).join(' '); - - const isProtected = node.isProtected === true; - const isReadonly = node.isReadonly === true; - const notDraggable = isProtected || isReadonly; - const notEditable = isProtected || isReadonly; - const customIcon = node.icon; - - return ( -
-
sel.onItemClick(node.id, 'folder', e)} - draggable={!notDraggable} - onDragStart={notDraggable ? undefined : (e) => { - sel.onItemDragStart(e, node.id, 'folder', node.name); - setDragging(true); - }} - onDragEnd={notDraggable ? undefined : () => setDragging(false)} - onDragOver={isProtected ? undefined : _handleDragOver} - onDragLeave={isProtected ? undefined : _handleDragLeave} - onDrop={isProtected ? undefined : _handleDrop} - > - { e.stopPropagation(); if (hasChildren) onToggle(node.id); }} - > - - - - {customIcon ? ( - {customIcon} - ) : isExpanded ? : } - - {renaming && !notEditable ? ( - setRenameValue(e.target.value)} - onBlur={_handleRename} - onKeyDown={(e) => { - if (e.key === 'Enter') _handleRename(); - if (e.key === 'Escape') setRenaming(false); - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {node.name} - )} - {!isProtected && ( - - - {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {isMultiSelected && sel.selectedItemIds.size > 1 ? ( - <> - {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( - - )} - {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( - - )} - - ) : !notEditable && onDeleteFolder && ( - - )} - - <_StableTrio - scope={node.scope} - neutralize={node.neutralize} - scopeLabels={scopeLabels} - onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined} - onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined} - onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined} - chatTitle={t('In Chat senden')} - /> - - )} -
- {isExpanded && hasChildren && ( -
- {node.children!.map((child) => ( - <_TreeNode - key={child.id} - node={child} - depth={depth + 1} - selectedFolderId={selectedFolderId} - expandedIds={expandedIds} - showFiles={showFiles} - filesByFolder={filesByFolder} - sel={sel} - promptFolderName={promptFolderName} - onToggle={onToggle} - onSelect={onSelect} - onCreateFolder={onCreateFolder} - onRenameFolder={onRenameFolder} - onDeleteFolder={onDeleteFolder} - onMoveFolder={onMoveFolder} - onMoveFolders={onMoveFolders} - onMoveFile={onMoveFile} - onMoveFiles={onMoveFiles} - onDownloadFolder={onDownloadFolder} - onFolderScopeChange={onFolderScopeChange} - onFolderNeutralizeToggle={onFolderNeutralizeToggle} - /> - ))} - {folderFiles.map((file) => ( - <_FileItem key={file.id} file={file} sel={sel} /> - ))} -
- )} -
- ); -} - -/* ── Root component ────────────────────────────────────────────────────── */ - -export default function FolderTree({ - folders, files, showFiles = false, selectedFolderId, onSelect, onFileSelect, - selectedItemIds: externalSelectedIds, onSelectionChange, - expandedIds: externalExpandedIds, onToggleExpand, - onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, - onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, - onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat, - customActions, udbContext, -}: FolderTreeProps) { - const { t } = useLanguage(); - const viewMode = useViewMode(); - const containerRef = useRef(null); - const inlineRenameRegistryRef = useRef void>>(new Map()); - const _registerInlineRename = useCallback((fileId: string, fn: () => void) => { - inlineRenameRegistryRef.current.set(fileId, fn); - }, []); - - const [menuState, setMenuState] = useState<{ - anchor: { x: number; y: number }; - target: FileActionTarget; - title?: string; - } | null>(null); - const [sheetState, setSheetState] = useState<{ - target: FileActionTarget; - title?: string; - } | null>(null); - - const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); - const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); - const lastClickedIdRef = useRef(null); - const { prompt: promptFolderName, PromptDialog } = usePrompt(); - const [rootDropOver, setRootDropOver] = useState(false); - - const expandedIds = externalExpandedIds ?? internalExpandedIds; - const selectedItemIds = externalSelectedIds ?? internalSelectedIds; - - const realTree = useMemo(() => _buildTree(folders), [folders]); - const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]); - const rootFiles = showFiles ? (filesByFolder.get('') || []) : []; - - const knownFolderIds = useMemo(() => { - const ids = new Set(); - const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } }; - _collect(realTree); - return ids; - }, [realTree]); - - const tree = useMemo(() => { - if (!showFiles) return realTree; - const orphanFolders: FolderNode[] = []; - for (const key of filesByFolder.keys()) { - if (key && !knownFolderIds.has(key)) { - orphanFolders.push({ id: key, name: key.slice(0, 8) + '…', parentId: null, fileCount: filesByFolder.get(key)?.length ?? 0, isProtected: true }); - } - } - if (orphanFolders.length === 0) return realTree; - return [...realTree, ...orphanFolders.sort((a, b) => a.name.localeCompare(b.name))]; - }, [realTree, showFiles, filesByFolder, knownFolderIds]); - - const flatList = useMemo( - () => _computeFlatList(tree, expandedIds, showFiles, filesByFolder), - [tree, expandedIds, showFiles, filesByFolder], - ); - - const _handleToggle = useCallback((id: string) => { - if (onToggleExpand) { - onToggleExpand(id); - return; - } - setInternalExpandedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); - return next; - }); - }, [onToggleExpand]); - - const _setSelection = useCallback((ids: Set) => { - if (onSelectionChange) { - onSelectionChange(ids); - } else { - setInternalSelectedIds(ids); - } - }, [onSelectionChange]); - - const _handleItemClick = useCallback((id: string, type: 'file' | 'folder', e: React.MouseEvent) => { - if (e.ctrlKey || e.metaKey) { - const next = new Set(selectedItemIds); - if (next.has(id)) next.delete(id); else next.add(id); - _setSelection(next); - lastClickedIdRef.current = id; - return; - } - - if (e.shiftKey && lastClickedIdRef.current) { - const lastIdx = flatList.findIndex(i => i.id === lastClickedIdRef.current); - const currIdx = flatList.findIndex(i => i.id === id); - if (lastIdx >= 0 && currIdx >= 0) { - const [from, to] = lastIdx < currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx]; - const next = new Set(selectedItemIds); - for (let i = from; i <= to; i++) next.add(flatList[i].id); - _setSelection(next); - } - return; - } - - _setSelection(new Set([id])); - lastClickedIdRef.current = id; - if (type === 'folder') onSelect(id); - if (type === 'file') onFileSelect?.(id); - }, [selectedItemIds, flatList, _setSelection, onSelect, onFileSelect]); - - const _handleItemDragStart = useCallback((e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => { - const isInSelection = selectedItemIds.has(id) && selectedItemIds.size > 1; - - if (isInSelection) { - const items: TreeItem[] = []; - for (const selId of selectedItemIds) { - const item = flatList.find(i => i.id === selId); - if (item) items.push(item); - } - e.dataTransfer.setData('application/tree-items', JSON.stringify(items)); - const fileIds = items.filter(i => i.type === 'file').map(i => i.id); - if (fileIds.length > 0) { - e.dataTransfer.setData('application/file-ids', JSON.stringify(fileIds)); - } - } else { - e.dataTransfer.setData('application/tree-items', JSON.stringify([{ id, type, name }])); - if (type === 'file') { - e.dataTransfer.setData('application/file-id', id); - } else { - e.dataTransfer.setData('application/folder-id', id); - } - } - e.dataTransfer.effectAllowed = 'copyMove'; - }, [selectedItemIds, flatList]); - - const allFileIds = useMemo(() => { - const ids = new Set(); - for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id); - return ids; - }, [filesByFolder]); - - const allFolderIds = useMemo(() => { - const ids = new Set(); - const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } }; - _collect(tree); - return ids; - }, [tree]); - - const _beginInlineRename = useCallback((fileId: string) => { - const fn = inlineRenameRegistryRef.current.get(fileId); - if (fn) fn(); - }, []); - - const actionCtx: FileActionContext = useMemo(() => ({ - viewMode, - udbContext, - }), [viewMode, udbContext]); - - const fileActions = useFileActions(actionCtx, customActions, { - onRenameFile, - onDeleteFile, - onDeleteFiles, - onDeleteFolders, - onSendToChat, - t, - beginInlineRename: _beginInlineRename, - }); - - const _openMenu = useCallback( - (anchor: { x: number; y: number }, target: FileActionTarget, title?: string) => { - setMenuState({ anchor, target, title }); - }, - [], - ); - const _openSheet = useCallback((target: FileActionTarget, title?: string) => { - setSheetState({ target, title }); - }, []); - const _closeMenu = useCallback(() => setMenuState(null), []); - const _closeSheet = useCallback(() => setSheetState(null), []); - - const _applyDragPayload = useCallback( - (e: React.DragEvent, target: FileActionTarget) => { - const drag = fileActions.forTarget(target).drag; - for (const a of drag) { - if (!a.dragMime) continue; - try { - e.dataTransfer.setData( - a.dragMime, - JSON.stringify({ - actionId: a.id, - files: target.files.map((f) => ({ id: f.id, name: f.fileName })), - folders: target.folders.map((f) => ({ id: f.id, name: f.name })), - }), - ); - } catch { - // dataTransfer.setData kann in seltenen Fällen werfen (read-only) — nicht fatal. - } - } - }, - [fileActions], - ); - - const sel: SelectionCtx = useMemo(() => { - const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id)); - const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id)); - return { - selectedItemIds, - selectedFileIds: selFileIds, - selectedFolderIds: selFolderIds, - onItemClick: _handleItemClick, - onItemDragStart: _handleItemDragStart, - onRenameFile, - onDeleteFile, - onDeleteFiles, - onDeleteFolders, - onScopeChange, - onNeutralizeToggle, - onSendToChat, - actions: { - actionCtx, - resolveFor: fileActions.forTarget, - openMenu: _openMenu, - openSheet: _openSheet, - applyDragPayload: _applyDragPayload, - }, - registerInlineRename: _registerInlineRename, - }; - }, [ - selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, - onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, - onScopeChange, onNeutralizeToggle, onSendToChat, - actionCtx, fileActions.forTarget, _openMenu, _openSheet, _applyDragPayload, _registerInlineRename, - ]); - - // Tastenkürzel — nur dispatchen wenn FolderTree den Fokus enthält und es nicht aus - // einem Input/Editable-Element kommt (sonst kollidiert F2/Delete mit Inline-Rename). - useEffect(() => { - const _onKeyDown = (e: KeyboardEvent) => { - const root = containerRef.current; - if (!root) return; - const active = document.activeElement as HTMLElement | null; - if (!active || !root.contains(active)) return; - const tag = active.tagName.toLowerCase(); - if (tag === 'input' || tag === 'textarea' || active.isContentEditable) return; - - const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id)); - const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id)); - if (selFileIds.length + selFolderIds.length === 0) return; - - const allFiles = (files ?? []).filter(f => selFileIds.includes(f.id)); - // Folder-Ziele für Shortcuts kommen aktuell nicht vor — Built-in `core.delete` - // operiert auf der Selection. Für diese Iteration genügt das. - const target: FileActionTarget = { files: allFiles, folders: [] }; - const resolved = fileActions.forTarget(target).shortcut; - - for (const a of resolved) { - if (!a.shortcut) continue; - if (_matchesShortcut(e, a.shortcut)) { - e.preventDefault(); - void runAction(a, target, actionCtx, _windowConfirm); - return; - } - } - }; - window.addEventListener('keydown', _onKeyDown); - return () => window.removeEventListener('keydown', _onKeyDown); - }, [selectedItemIds, allFileIds, allFolderIds, files, fileActions, actionCtx]); - - // Root drop handler: items dropped on the empty area go to root (null) - const _handleRootDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - setRootDropOver(false); - const treeItemsJson = e.dataTransfer.getData('application/tree-items'); - if (treeItemsJson) { - const items: TreeItem[] = JSON.parse(treeItemsJson); - const fileIds = items.filter(i => i.type === 'file').map(i => i.id); - const folderIds = items.filter(i => i.type === 'folder').map(i => i.id); - if (folderIds.length > 0 && onMoveFolders) await onMoveFolders(folderIds, null); - else if (onMoveFolder) for (const fId of folderIds) await onMoveFolder(fId, null); - if (fileIds.length > 0 && onMoveFiles) await onMoveFiles(fileIds, null); - else if (onMoveFile) for (const fId of fileIds) await onMoveFile(fId, null); - return; - } - - const folderId = e.dataTransfer.getData('application/folder-id'); - const fileId = e.dataTransfer.getData('application/file-id'); - if (folderId && onMoveFolder) await onMoveFolder(folderId, null); - else if (fileId && onMoveFile) await onMoveFile(fileId, null); - }, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]); - - const _handleRootAddFolder = useCallback(async () => { - if (!onCreateFolder) return; - const name = await promptFolderName(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') }); - if (name?.trim()) await onCreateFolder(name.trim(), null); - }, [onCreateFolder, promptFolderName, t]); - - const isRootSelected = selectedFolderId === null; - - const _handleRootClick = useCallback(() => { - _setSelection(new Set()); - onSelect(null); - }, [_setSelection, onSelect]); - - const menuActions = useMemo( - () => (menuState ? fileActions.forTarget(menuState.target).menu : []), - [menuState, fileActions], - ); - const sheetActions = useMemo( - () => (sheetState ? fileActions.forTarget(sheetState.target).sheet : []), - [sheetState, fileActions], - ); - - return ( -
-
- { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setRootDropOver(true); }} - onDragLeave={() => setRootDropOver(false)} - onDrop={_handleRootDrop} - > - / - - - {onCreateFolder && ( - - )} - {onRefresh && ( - - )} - -
-
- {tree.map((node) => ( - <_TreeNode - key={node.id} - node={node} - depth={0} - selectedFolderId={selectedFolderId} - expandedIds={expandedIds} - showFiles={showFiles} - filesByFolder={filesByFolder} - sel={sel} - promptFolderName={promptFolderName} - onToggle={_handleToggle} - onSelect={onSelect} - onCreateFolder={onCreateFolder} - onRenameFolder={onRenameFolder} - onDeleteFolder={onDeleteFolder} - onMoveFolder={onMoveFolder} - onMoveFolders={onMoveFolders} - onMoveFile={onMoveFile} - onMoveFiles={onMoveFiles} - onDownloadFolder={onDownloadFolder} - onFolderScopeChange={onFolderScopeChange} - onFolderNeutralizeToggle={onFolderNeutralizeToggle} - /> - ))} - {rootFiles.map((file) => ( - <_FileItem key={file.id} file={file} sel={sel} /> - ))} -
- - {menuState && ( - - )} - -
- ); -} diff --git a/src/components/FolderTree/SharepointBrowseTree.tsx b/src/components/FolderTree/SharepointBrowseTree.tsx deleted file mode 100644 index b344c51..0000000 --- a/src/components/FolderTree/SharepointBrowseTree.tsx +++ /dev/null @@ -1,319 +0,0 @@ -/** - * SharepointBrowseTree – Lazy-loading tree for SharePoint browse. - * Same look & feel as FolderTree (chevron, FaFolder/FaFolderOpen, styling). - * Loads children on expand via onLoadChildren(path). - */ - -import React, { useState, useCallback, useEffect } from 'react'; -import { FaFolder, FaFolderOpen, FaChevronRight, FaGlobe } from 'react-icons/fa'; -import styles from './FolderTree.module.css'; -import { useLanguage } from '../../providers/language/LanguageContext'; - -export interface BrowseEntry { - name: string; - path: string; - isFolder: boolean; - size?: number; - mimeType?: string; - metadata?: Record; -} - -export interface SharepointBrowseTreeProps { - /** Root path (usually "/") - children loaded via onLoadChildren */ - rootPath?: string; - /** Load children for a given path. Returns folders and files. */ - onLoadChildren: (path: string) => Promise; - /** Called when user selects a file path */ - onSelectFile: (path: string) => void; - /** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */ - onSelectFolder?: (path: string) => void; - /** If true, file rows are not shown — only folders (for list/upload/destination folder pickers). */ - foldersOnly?: boolean; - /** Currently selected path (for highlight) */ - selectedPath?: string | null; - /** Optional: pre-seed root children (e.g. from initial load) */ - initialChildren?: BrowseEntry[]; -} - -function _fileIcon(mime?: string): string { - if (!mime) return '\uD83D\uDCC4'; - if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; - if (mime.includes('pdf')) return '\uD83D\uDCD5'; - if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8'; - if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA'; - if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9'; - if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6'; - if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD'; - return '\uD83D\uDCC4'; -} - -/* ── File row ──────────────────────────────────────────────────────────── */ - -function _FileRow({ - entry, - selectedPath, - onSelect, -}: { - entry: BrowseEntry; - selectedPath: string | null | undefined; - onSelect: (path: string) => void; -}) { - const isSelected = selectedPath === entry.path; - - return ( -
onSelect(entry.path)} - title={entry.path} - > - - {_fileIcon(entry.mimeType)} - {entry.name} - {entry.size != null && ( - - {(entry.size / 1024).toFixed(0)}K - - )} -
- ); -} - -/* ── Folder row (expandable, lazy-loads children) ───────────────────────── */ - -function _FolderRow({ - entry, - selectedPath, - expandedPaths, - loadedChildren, - loadingPaths, - onToggle, - onSelectFile, - onSelectFolder, - foldersOnly, -}: { - entry: BrowseEntry; - selectedPath: string | null | undefined; - expandedPaths: Set; - loadedChildren: Record; - loadingPaths: Set; - onToggle: (path: string) => void; - onSelectFile: (path: string) => void; - onSelectFolder?: (path: string) => void; - foldersOnly: boolean; -}) { - const { t } = useLanguage(); - const isExpanded = expandedPaths.has(entry.path); - const isSelected = selectedPath === entry.path; - const children = loadedChildren[entry.path] ?? []; - const folders = children.filter((c) => c.isFolder).sort((a, b) => a.name.localeCompare(b.name)); - const files = children.filter((c) => !c.isFolder).sort((a, b) => a.name.localeCompare(b.name)); - const isLoading = isExpanded && loadingPaths.has(entry.path); - - const handleRowClick = (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if (target.closest(`.${styles.chevron}`)) return; - if (onSelectFolder) { - onSelectFolder(entry.path); - return; - } - onToggle(entry.path); - }; - - const handleChevronClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onToggle(entry.path); - }; - - return ( -
-
- - - - - {isExpanded ? : } - - {entry.name} - {isLoading && ( - - )} -
- {isExpanded && ( -
- {isLoading ? ( -
- {t('Wird geladen…')} -
- ) : ( - <> - {folders.map((child) => ( - <_FolderRow - key={child.path} - entry={child} - selectedPath={selectedPath} - expandedPaths={expandedPaths} - loadedChildren={loadedChildren} - loadingPaths={loadingPaths} - onToggle={onToggle} - onSelectFile={onSelectFile} - onSelectFolder={onSelectFolder} - foldersOnly={foldersOnly} - /> - ))} - {!foldersOnly && - files.map((child) => ( - <_FileRow - key={child.path} - entry={child} - selectedPath={selectedPath} - onSelect={onSelectFile} - /> - ))} - {children.length === 0 && ( -
- {t('Leer')} -
- )} - - )} -
- )} -
- ); -} - -/* ── Root component ─────────────────────────────────────────────────────── */ - -export function SharepointBrowseTree({ - rootPath = '/', - onLoadChildren, - onSelectFile, - onSelectFolder, - foldersOnly = false, - selectedPath, - initialChildren = [], -}: SharepointBrowseTreeProps) { - const { t } = useLanguage(); - const [expandedPaths, setExpandedPaths] = useState>(new Set([rootPath])); - const [loadedChildren, setLoadedChildren] = useState>(() => - initialChildren.length > 0 ? { [rootPath]: initialChildren } : {} - ); - const [loadingPaths, setLoadingPaths] = useState>(new Set()); - - const loadPath = useCallback( - async (path: string) => { - setLoadingPaths((p) => new Set(p).add(path)); - try { - const items = await onLoadChildren(path); - setLoadedChildren((prev) => ({ ...prev, [path]: items })); - } catch { - setLoadedChildren((prev) => ({ ...prev, [path]: [] })); - } finally { - setLoadingPaths((p) => { - const next = new Set(p); - next.delete(path); - return next; - }); - } - }, - [onLoadChildren] - ); - - const handleToggle = useCallback( - (path: string) => { - setExpandedPaths((prev) => { - const next = new Set(prev); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - loadPath(path); - } - return next; - }); - }, - [loadPath] - ); - - useEffect(() => { - if (rootPath in loadedChildren) return; - if (initialChildren.length > 0) return; - loadPath(rootPath); - }, [rootPath, initialChildren.length, loadPath]); - - const rootItems = loadedChildren[rootPath] ?? []; - const rootLoading = loadingPaths.has(rootPath); - const rootFolders = rootItems.filter((e) => e.isFolder).sort((a, b) => a.name.localeCompare(b.name)); - const rootFiles = rootItems.filter((e) => !e.isFolder).sort((a, b) => a.name.localeCompare(b.name)); - const isRootExpanded = expandedPaths.has(rootPath); - - return ( -
-
- handleToggle(rootPath)} - title={isRootExpanded ? t('Einklappen') : t('Erweitern')} - > - - - - {t('SharePoint')} - {rootLoading && ( - - )} -
- {isRootExpanded && ( -
- {rootLoading ? ( -
- {t('Sites werden geladen…')} -
- ) : ( - <> - {rootFolders.map((entry) => ( - <_FolderRow - key={entry.path} - entry={entry} - selectedPath={selectedPath} - expandedPaths={expandedPaths} - loadedChildren={loadedChildren} - loadingPaths={loadingPaths} - onToggle={handleToggle} - onSelectFile={onSelectFile} - onSelectFolder={onSelectFolder} - foldersOnly={foldersOnly} - /> - ))} - {!foldersOnly && - rootFiles.map((entry) => ( - <_FileRow - key={entry.path} - entry={entry} - selectedPath={selectedPath} - onSelect={onSelectFile} - /> - ))} - {rootItems.length === 0 && !rootLoading && ( -
- {t('Keine Einträge')} -
- )} - - )} -
- )} -
- ); -} diff --git a/src/components/FolderTree/actions/FileActionBottomSheet.module.css b/src/components/FolderTree/actions/FileActionBottomSheet.module.css deleted file mode 100644 index 49f7d08..0000000 --- a/src/components/FolderTree/actions/FileActionBottomSheet.module.css +++ /dev/null @@ -1,103 +0,0 @@ -/* Bottom-Sheet für FolderTree Long-Press (Mobile). */ - -@keyframes _slideUp { - from { transform: translateY(100%); } - to { transform: translateY(0); } -} - -@keyframes _fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -.backdrop { - position: fixed; - inset: 0; - z-index: 1000; - background: rgba(0, 0, 0, 0.45); - animation: _fadeIn 0.15s ease-out; -} - -.sheet { - position: fixed; - left: 0; - right: 0; - bottom: 0; - z-index: 1001; - background: var(--color-bg-elevated, #ffffff); - border-top-left-radius: 16px; - border-top-right-radius: 16px; - box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.18); - padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px)); - max-height: 80vh; - overflow-y: auto; - animation: _slideUp 0.18s ease-out; -} - -.handle { - width: 36px; - height: 4px; - border-radius: 2px; - background: var(--color-border, rgba(0, 0, 0, 0.18)); - margin: 4px auto 8px; -} - -.title { - padding: 4px 16px 12px; - font-size: 13px; - font-weight: 600; - color: var(--color-text-primary, #222); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.06)); - margin-bottom: 4px; -} - -.item { - display: flex; - align-items: center; - gap: 14px; - width: 100%; - min-height: 48px; - padding: 12px 16px; - border: none; - background: none; - cursor: pointer; - color: var(--color-text-primary, #222); - text-align: left; - font-size: 15px; - line-height: 1.2; -} - -.item:active { - background: var(--color-bg-hover, rgba(25, 118, 210, 0.10)); -} - -.item.danger { - color: var(--color-error, #d32f2f); -} - -.icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - font-size: 17px; - flex-shrink: 0; -} - -.label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.empty { - padding: 16px; - text-align: center; - font-size: 13px; - color: var(--color-text-secondary, #999); - font-style: italic; -} diff --git a/src/components/FolderTree/actions/FileActionBottomSheet.tsx b/src/components/FolderTree/actions/FileActionBottomSheet.tsx deleted file mode 100644 index efe5f62..0000000 --- a/src/components/FolderTree/actions/FileActionBottomSheet.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/** - * FileActionBottomSheet — Long-Press Action-Sheet für Mobile. - * - * Slide-Up von unten, 48 px Touch-Targets, ESC + Backdrop schließen. - */ - -import React, { useEffect } from 'react'; -import { - type FileAction, - type FileActionContext, - type FileActionTarget, - resolveActionLabel, -} from './types'; -import { runAction } from './registry'; -import styles from './FileActionBottomSheet.module.css'; - -interface Props { - open: boolean; - actions: FileAction[]; - target: FileActionTarget; - ctx: FileActionContext; - onClose: () => void; - title?: string; - confirm?: (title: string, body: string) => boolean | Promise; -} - -export const FileActionBottomSheet: React.FC = ({ - open, - actions, - target, - ctx, - onClose, - title, - confirm, -}) => { - useEffect(() => { - if (!open) return; - const _onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', _onKey); - return () => window.removeEventListener('keydown', _onKey); - }, [open, onClose]); - - if (!open) return null; - - const _handleClick = async (action: FileAction) => { - onClose(); - await runAction(action, target, ctx, confirm); - }; - - return ( - <> -
-
- - - ); -}; diff --git a/src/components/FolderTree/actions/FileActionContextMenu.module.css b/src/components/FolderTree/actions/FileActionContextMenu.module.css deleted file mode 100644 index 3307011..0000000 --- a/src/components/FolderTree/actions/FileActionContextMenu.module.css +++ /dev/null @@ -1,103 +0,0 @@ -/* Context-Menu für FolderTree (Right-Click). - * Floating, ARIA-menu, Backdrop-Click + ESC schließen. */ - -.backdrop { - position: fixed; - inset: 0; - z-index: 1000; - background: transparent; -} - -.menu { - position: fixed; - z-index: 1001; - min-width: 200px; - max-width: 320px; - background: var(--color-bg-elevated, #ffffff); - border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12)); - border-radius: 6px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); - padding: 4px 0; - font-size: 13px; - color: var(--color-text-primary, #222); - user-select: none; -} - -.header { - padding: 4px 12px 6px; - font-size: 11px; - font-weight: 600; - color: var(--color-text-secondary, #888); - text-transform: uppercase; - letter-spacing: 0.04em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.divider { - height: 1px; - margin: 4px 0; - background: var(--color-border, rgba(0, 0, 0, 0.08)); -} - -.item { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 6px 12px; - border: none; - background: none; - cursor: pointer; - color: inherit; - text-align: left; - font: inherit; - line-height: 1.2; -} - -.item:hover, -.item:focus-visible { - background: var(--color-bg-hover, rgba(25, 118, 210, 0.08)); - outline: none; -} - -.item.danger { - color: var(--color-error, #d32f2f); -} - -.item.danger:hover, -.item.danger:focus-visible { - background: var(--color-bg-error, rgba(211, 47, 47, 0.08)); -} - -.icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - font-size: 13px; - flex-shrink: 0; -} - -.label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.shortcut { - flex-shrink: 0; - font-size: 11px; - color: var(--color-text-secondary, #999); - font-family: ui-monospace, SFMono-Regular, "SF Mono", monospace; - padding-left: 12px; -} - -.empty { - padding: 8px 12px; - font-size: 12px; - color: var(--color-text-secondary, #999); - font-style: italic; -} diff --git a/src/components/FolderTree/actions/FileActionContextMenu.tsx b/src/components/FolderTree/actions/FileActionContextMenu.tsx deleted file mode 100644 index 64e4a1c..0000000 --- a/src/components/FolderTree/actions/FileActionContextMenu.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * FileActionContextMenu — Floating Right-Click-Menu für FolderTree. - * - * Wird vom FolderTree gemountet wenn `onContextMenu` auf einer Zeile feuert. - * Schließt sich bei Backdrop-Klick, ESC oder nach Aktion-Dispatch. - */ - -import React, { useEffect, useRef } from 'react'; -import { - type FileAction, - type FileActionContext, - type FileActionTarget, - resolveActionLabel, -} from './types'; -import { runAction } from './registry'; -import styles from './FileActionContextMenu.module.css'; - -interface Props { - /** Sichtbar/positioniert. ``null`` → nicht gemountet. */ - anchor: { x: number; y: number } | null; - actions: FileAction[]; - target: FileActionTarget; - ctx: FileActionContext; - /** Wird aufgerufen sobald das Menü schließen soll (Backdrop, ESC, nach Action). */ - onClose: () => void; - /** Optional: Header-Label (z. B. Dateiname). */ - title?: string; - /** Optionaler Confirm-Provider (z. B. browser native ``window.confirm``). */ - confirm?: (title: string, body: string) => boolean | Promise; -} - -export const FileActionContextMenu: React.FC = ({ - anchor, - actions, - target, - ctx, - onClose, - title, - confirm, -}) => { - const menuRef = useRef(null); - - useEffect(() => { - if (!anchor) return; - const _onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', _onKey); - return () => window.removeEventListener('keydown', _onKey); - }, [anchor, onClose]); - - useEffect(() => { - if (!anchor || !menuRef.current) return; - menuRef.current.focus(); - }, [anchor]); - - if (!anchor) return null; - - const adjusted = _adjustToViewport(anchor, menuRef.current); - - const _handleClick = async (action: FileAction) => { - onClose(); - await runAction(action, target, ctx, confirm); - }; - - return ( - <> -
{ - e.preventDefault(); - onClose(); - }} - /> -
- {title &&
{title}
} - {actions.length === 0 ? ( -
- ) : ( - actions.map((a, idx) => { - const Icon = a.icon; - const isDangerCls = a.danger ? `${styles.item} ${styles.danger}` : styles.item; - return ( - - {idx > 0 && a.danger && actions[idx - 1] && !actions[idx - 1].danger && ( -
- )} - - - ); - }) - )} -
- - ); -}; - -function _adjustToViewport( - anchor: { x: number; y: number }, - menu: HTMLDivElement | null, -): { x: number; y: number } { - if (!menu) return anchor; - const rect = menu.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - const margin = 4; - let x = anchor.x; - let y = anchor.y; - if (x + rect.width + margin > vw) x = Math.max(margin, vw - rect.width - margin); - if (y + rect.height + margin > vh) y = Math.max(margin, vh - rect.height - margin); - return { x, y }; -} - -function _formatShortcut(s: string): string { - const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); - return s - .split('+') - .map((part) => { - const k = part.trim().toLowerCase(); - if (k === 'mod') return isMac ? '\u2318' : 'Ctrl'; - if (k === 'shift') return isMac ? '\u21E7' : 'Shift'; - if (k === 'alt') return isMac ? '\u2325' : 'Alt'; - if (k === 'ctrl') return 'Ctrl'; - return k.length === 1 ? k.toUpperCase() : part; - }) - .join(isMac ? '' : '+'); -} diff --git a/src/components/FolderTree/actions/registry.ts b/src/components/FolderTree/actions/registry.ts deleted file mode 100644 index c2b83bb..0000000 --- a/src/components/FolderTree/actions/registry.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * useFileActions — zentraler Registry-Hook für FolderTree Aktionen. - * - * Liefert eine einheitliche, gefilterte und sortierte Aktion-Liste, die das - * `FolderTree`-Inneres an Right-Click-Menü, Long-Press-Sheet, Tastenkürzel und - * Drag-Source dispatched. Built-in-Aktionen (Rename, Delete, Send-to-Chat) - * werden aus den vorhandenen FolderTree-Callbacks abgeleitet, damit existierende - * Aufrufer nichts ändern müssen. - */ - -import { useMemo } from 'react'; -import { FaPen, FaTrash, FaCommentDots } from 'react-icons/fa'; -import { - type FileAction, - type FileActionContext, - type FileActionTarget, - resolveActionLabel, -} from './types'; - -/** Callback-Bündel mit den heutigen `FolderTreeProps`-Handlern. - * Optional, weil nicht jeder Aufrufer alle Built-ins anbietet. */ -export interface BuiltinCallbacks { - onRenameFile?: (fileId: string, newName: string) => Promise; - onDeleteFile?: (fileId: string) => Promise; - onDeleteFiles?: (fileIds: string[]) => Promise; - onDeleteFolders?: (folderIds: string[]) => Promise; - onSendToChat?: ( - items: Array<{ id: string; type: 'file' | 'folder'; name: string }>, - ) => void; - /** Translator (i18n) — typischerweise `t` aus dem LanguageContext. */ - t?: (key: string, vars?: Record) => string; - /** Inline-Rename-Trigger (Eingabefeld in der Zeile). Wird vom FolderTree - * intern bereitgestellt — nicht vom Aufrufer. */ - beginInlineRename?: (fileId: string) => void; -} - -/** Sortierte, gefilterte Aktionsliste pro Kanal. */ -export interface ResolvedActions { - inline: FileAction[]; - menu: FileAction[]; - sheet: FileAction[]; - shortcut: FileAction[]; - drag: FileAction[]; -} - -const _IDENTITY: NonNullable = (s) => s; - -/** Built-in-Definitionen, die aus den heute hartcodierten Callbacks abgeleitet werden. - * Diese erscheinen NUR in den neuen Kanälen (Menu, Sheet, Shortcut) — die Inline-Icons - * werden weiterhin direkt vom FolderTree-Renderer gezeichnet, damit die bestehende - * "Stable-Trio + dynamische Aktionen"-Logik unangetastet bleibt. */ -function _buildBuiltins(cb: BuiltinCallbacks): FileAction[] { - const t: NonNullable = cb.t ?? _IDENTITY; - const list: FileAction[] = []; - - if (cb.onSendToChat) { - list.push({ - id: 'core.sendToChat', - label: t('In Chat senden'), - icon: FaCommentDots, - scope: 'multi', - channels: ['menu', 'sheet'], - sortOrder: 100, - handler: ({ files, folders }) => { - const items = [ - ...files.map((f) => ({ id: f.id, type: 'file' as const, name: f.fileName })), - ...folders.map((f) => ({ id: f.id, type: 'folder' as const, name: f.name })), - ]; - if (items.length > 0) cb.onSendToChat!(items); - }, - }); - } - - if (cb.onRenameFile && cb.beginInlineRename) { - list.push({ - id: 'core.rename', - label: t('Umbenennen'), - icon: FaPen, - scope: 'file', - channels: ['menu', 'sheet', 'shortcut'], - shortcut: 'F2', - sortOrder: 110, - predicate: ({ files, folders }) => files.length === 1 && folders.length === 0, - handler: ({ files }) => { - if (files.length === 1) cb.beginInlineRename!(files[0].id); - }, - }); - } - - if (cb.onDeleteFile || cb.onDeleteFiles || cb.onDeleteFolders) { - list.push({ - id: 'core.delete', - label: ({ files, folders }) => - files.length + folders.length > 1 - ? t('{count} Einträge löschen', { count: String(files.length + folders.length) }) - : t('Löschen'), - icon: FaTrash, - scope: 'multi', - channels: ['menu', 'sheet', 'shortcut'], - shortcut: 'Delete', - danger: true, - sortOrder: 200, - predicate: ({ files, folders }) => files.length > 0 || folders.length > 0, - confirm: { - title: t('Löschen bestätigen'), - body: ({ files, folders }) => - files.length + folders.length > 1 - ? t('{count} Einträge löschen?', { - count: String(files.length + folders.length), - }) - : t('Diesen Eintrag löschen?'), - }, - handler: async ({ files, folders }) => { - if (folders.length > 0 && cb.onDeleteFolders) { - await cb.onDeleteFolders(folders.map((f) => f.id)); - } - if (files.length > 1 && cb.onDeleteFiles) { - await cb.onDeleteFiles(files.map((f) => f.id)); - } else if (files.length === 1) { - if (cb.onDeleteFile) await cb.onDeleteFile(files[0].id); - else if (cb.onDeleteFiles) await cb.onDeleteFiles([files[0].id]); - } - }, - }); - } - - return list; -} - -/** - * Zentrale Registry-Hook. - * - * @param ctx Aktueller Aufruf-Kontext (View-Mode, Mandant, …). - * @param customs Vom Aufrufer registrierte Custom-Actions (Plugin-Slot). - * @param builtins Callback-Bündel der Built-in-Aktionen (aus FolderTreeProps abgeleitet). - * - * Die Rückgabe ist memoized und pro Kanal vorgefiltert; ein `Predicate`-Check - * pro Target erfolgt zusätzlich erst beim Render der jeweiligen Zeile/Sheet. - */ -export function useFileActions( - ctx: FileActionContext, - customs: FileAction[] | undefined, - builtins: BuiltinCallbacks, -): { - /** Alle Aktionen (gemerged + sortiert), unfiltered nach Predicate. */ - all: FileAction[]; - /** Liefert die für ein konkretes Target sichtbaren Aktionen, gruppiert nach Kanal. */ - forTarget: (target: FileActionTarget) => ResolvedActions; -} { - const all = useMemo(() => { - const merged = [..._buildBuiltins(builtins), ...(customs ?? [])]; - merged.sort( - (a, b) => - (a.sortOrder ?? 1000) - (b.sortOrder ?? 1000) || a.id.localeCompare(b.id), - ); - return merged; - // We intentionally depend on each callback identity so re-renders pick up - // updated handlers (closures over instanceId etc.). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - customs, - builtins.onRenameFile, - builtins.onDeleteFile, - builtins.onDeleteFiles, - builtins.onDeleteFolders, - builtins.onSendToChat, - builtins.beginInlineRename, - builtins.t, - ]); - - const forTarget = useMemo(() => { - return (target: FileActionTarget): ResolvedActions => { - const _matches = (a: FileAction): boolean => { - if (a.scope === 'file' && (target.files.length !== 1 || target.folders.length > 0)) - return false; - if (a.scope === 'folder' && (target.folders.length !== 1 || target.files.length > 0)) - return false; - if (a.scope === 'multi' && target.files.length + target.folders.length === 0) - return false; - if (a.predicate && !a.predicate(target, ctx)) return false; - return true; - }; - - const matched = all.filter(_matches); - return { - inline: matched.filter((a) => a.channels.includes('inline')), - menu: matched.filter((a) => a.channels.includes('menu')), - sheet: matched.filter((a) => a.channels.includes('sheet')), - shortcut: matched.filter((a) => a.channels.includes('shortcut')), - drag: matched.filter((a) => a.channels.includes('drop')), - }; - }; - }, [all, ctx]); - - return { all, forTarget }; -} - -/** Hilfs-Dispatcher: führt Confirm + Handler aus, fängt Fehler ab und loggt sie. - * Der eigentliche Confirm-Dialog wird vom Renderer (Context-Menu/Sheet) bereitgestellt - * — dieser Helper bleibt UI-frei und ist von außerhalb React aufrufbar. */ -export async function runAction( - action: FileAction, - target: FileActionTarget, - ctx: FileActionContext, - confirmFn?: (label: string, body: string) => boolean | Promise, -): Promise { - if (action.confirm && confirmFn) { - const ok = await confirmFn(action.confirm.title, action.confirm.body(target)); - if (!ok) return; - } - try { - await action.handler(target, ctx); - } catch (err) { - console.error(`[FileAction] ${action.id} failed`, err); - } -} - -export { resolveActionLabel }; diff --git a/src/components/FolderTree/actions/types.ts b/src/components/FolderTree/actions/types.ts deleted file mode 100644 index d2b75db..0000000 --- a/src/components/FolderTree/actions/types.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Action-Modell für FolderTree (UDB Action System). - * - * Eine `FileAction` ist die kanonische Beschreibung einer Aktion, die der User - * auf eine Datei oder einen Ordner anwenden kann. Dieselbe Definition rendert - * sich automatisch in mehreren Kanälen: - * - inline → Icon-Button am rechten Zeilenrand - * - menu → Eintrag im Right-Click-Context-Menu - * - sheet → Eintrag im Long-Press Bottom-Sheet (Mobile) - * - shortcut → Tastenkürzel solange FolderTree Fokus hat - * - drop → Drag-Source: hängt eine zusätzliche MIME ans dataTransfer - * - * Vorhandene Built-in-Aktionen (Rename, Delete, Send-to-Chat) bleiben hinter - * dem System bestehen; wenn der Aufrufer keine `customActions` mitliefert, - * verhält sich `FolderTree` 1:1 wie zuvor. - */ - -import type React from 'react'; -import type { FileNode, FolderNode } from '../FolderTree'; - -export type FileActionScope = 'file' | 'folder' | 'multi'; -export type FileActionChannel = 'inline' | 'menu' | 'sheet' | 'shortcut' | 'drop'; - -/** UDB-Aufruf-Kontext — Aufrufer-Sites identifizieren sich, damit Predicates - * pro Surface entscheiden können (z. B. "nur im Graph-Editor sichtbar"). */ -export type UdbSurface = - | 'workspace' - | 'graphEditor' - | 'trustee' - | 'standalone' - | 'sharepoint'; - -export interface FileActionContext { - mandateId?: string; - featureInstanceId?: string; - viewMode: 'desktop' | 'mobile'; - udbContext?: UdbSurface; -} - -export interface FileActionTarget { - files: FileNode[]; - folders: FolderNode[]; -} - -export interface FileActionConfirm { - title: string; - body: (target: FileActionTarget) => string; -} - -export interface FileAction { - /** Global eindeutige Aktion-ID, namespace-prefixed (z. B. ``workflow.openInEditor``). */ - id: string; - /** Anzeige-Label (statisch oder als Funktion vom Target abgeleitet). */ - label: string | ((target: FileActionTarget) => string); - /** Icon-Komponente (react-icons-Style), bekommt optional `size`-Prop. */ - icon: React.ComponentType<{ size?: number }>; - /** Optionale Tönung des Icons (CSS color string). */ - iconColor?: string; - /** Was ist das Target — einzelne Datei, Ordner, oder Mehrfach-Selektion. */ - scope: FileActionScope; - /** Über welche UI-Kanäle wird die Aktion angeboten. */ - channels: FileActionChannel[]; - /** Pure, billig — entscheidet ob die Aktion für das aktuelle Target sichtbar ist. */ - predicate?: (target: FileActionTarget, ctx: FileActionContext) => boolean; - /** Async oder sync. Fehler werden vom Renderer geloggt; Toasts macht der Aufrufer. */ - handler: (target: FileActionTarget, ctx: FileActionContext) => Promise | void; - /** Tastenkürzel, z. B. `mod+e`. ``mod`` = Cmd auf Mac, Ctrl sonst. */ - shortcut?: string; - /** Wenn gesetzt → Bestätigungs-Dialog vor `handler`. */ - confirm?: FileActionConfirm; - /** MIME-Type für Drag-Source: wird zusätzlich ans `dataTransfer` gehängt. */ - dragMime?: string; - /** Sortier-Reihenfolge — kleinere Werte zuerst (Built-ins liegen bei 100, 110, 120…). */ - sortOrder?: number; - /** Visuell als gefährliche/destruktive Aktion markieren (rote Tönung). */ - danger?: boolean; -} - -/** Resolver-Helper: liest das Label eines `FileAction` aus, egal ob String oder Funktion. */ -export function resolveActionLabel(action: FileAction, target: FileActionTarget): string { - return typeof action.label === 'function' ? action.label(target) : action.label; -} - -/** Hilfs-Konstruktor: baut ein leeres Target. */ -export function emptyTarget(): FileActionTarget { - return { files: [], folders: [] }; -} diff --git a/src/components/FolderTree/actions/usePointerLongPress.ts b/src/components/FolderTree/actions/usePointerLongPress.ts deleted file mode 100644 index 18722c9..0000000 --- a/src/components/FolderTree/actions/usePointerLongPress.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useCallback, useRef } from 'react'; - -/** - * Long-Press-Erkennung über Pointer-Events. - * - * Liefert Handler die direkt auf `
` etc. gespreaded werden können. - * Ein "Long-Press" feuert nach `thresholdMs` (Default 500 ms) wenn der Pointer - * sich nicht weiter als `moveTolerance` Pixel bewegt hat. - */ - -interface LongPressOptions { - thresholdMs?: number; - moveTolerance?: number; - /** Wenn ``true``, werden auch Maus-Events behandelt (für Desktop-Smoke-Tests). */ - includeMouse?: boolean; -} - -interface LongPressHandlers { - onPointerDown: (e: React.PointerEvent) => void; - onPointerMove: (e: React.PointerEvent) => void; - onPointerUp: (e: React.PointerEvent) => void; - onPointerCancel: (e: React.PointerEvent) => void; - onPointerLeave: (e: React.PointerEvent) => void; -} - -export function usePointerLongPress( - callback: (e: React.PointerEvent) => void, - options: LongPressOptions = {}, -): LongPressHandlers { - const { thresholdMs = 500, moveTolerance = 8, includeMouse = false } = options; - const timerRef = useRef(null); - const startPosRef = useRef<{ x: number; y: number } | null>(null); - const firedRef = useRef(false); - - const _clear = useCallback(() => { - if (timerRef.current !== null) { - window.clearTimeout(timerRef.current); - timerRef.current = null; - } - startPosRef.current = null; - firedRef.current = false; - }, []); - - const onPointerDown = useCallback( - (e: React.PointerEvent) => { - if (!includeMouse && e.pointerType === 'mouse') return; - _clear(); - startPosRef.current = { x: e.clientX, y: e.clientY }; - firedRef.current = false; - timerRef.current = window.setTimeout(() => { - firedRef.current = true; - callback(e); - }, thresholdMs); - }, - [callback, includeMouse, thresholdMs, _clear], - ); - - const onPointerMove = useCallback( - (e: React.PointerEvent) => { - if (timerRef.current === null || !startPosRef.current) return; - const dx = e.clientX - startPosRef.current.x; - const dy = e.clientY - startPosRef.current.y; - if (Math.abs(dx) > moveTolerance || Math.abs(dy) > moveTolerance) _clear(); - }, - [moveTolerance, _clear], - ); - - return { - onPointerDown, - onPointerMove, - onPointerUp: _clear, - onPointerCancel: _clear, - onPointerLeave: _clear, - }; -} diff --git a/src/components/FolderTree/actions/useViewMode.ts b/src/components/FolderTree/actions/useViewMode.ts deleted file mode 100644 index 187b832..0000000 --- a/src/components/FolderTree/actions/useViewMode.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect, useState } from 'react'; - -/** - * Liefert den aktuellen View-Mode (`'desktop' | 'mobile'`) basierend auf - * Viewport-Breite + Touch-Heuristik. Mobile = Breite < 768 px ODER - * Touch-Primary-Pointer ohne Maus. - */ -export function useViewMode(): 'desktop' | 'mobile' { - const [mode, setMode] = useState<'desktop' | 'mobile'>(() => _detect()); - - useEffect(() => { - const _onResize = () => setMode(_detect()); - window.addEventListener('resize', _onResize); - return () => window.removeEventListener('resize', _onResize); - }, []); - - return mode; -} - -function _detect(): 'desktop' | 'mobile' { - if (typeof window === 'undefined') return 'desktop'; - const isNarrow = window.matchMedia('(max-width: 768px)').matches; - const isCoarse = window.matchMedia('(pointer: coarse)').matches; - return isNarrow || isCoarse ? 'mobile' : 'desktop'; -} diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css new file mode 100644 index 0000000..97a8592 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css @@ -0,0 +1,640 @@ +.formGeneratorTree { + display: flex; + flex-direction: column; + width: 100%; + font-family: var(--font-family); + min-height: 0; + flex: 1; + overflow: hidden; + height: 100%; + max-height: 100%; + position: relative; +} + +/* Section header */ +.sectionHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--color-border, #e2e8f0); + background: var(--table-header-bg, #edf0f5); + border-radius: 8px 8px 0 0; +} + +.sectionHeader:hover { + background: #e4e8ef; +} + +.sectionHeaderNonCollapsible { + cursor: default; +} + +.sectionHeaderNonCollapsible:hover { + background: var(--table-header-bg, #edf0f5); +} + +.collapseChevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 10px; + color: var(--color-text-secondary, #64748b); + transition: transform 0.2s ease; + flex-shrink: 0; +} + +.collapseChevronExpanded { + transform: rotate(90deg); +} + +.sectionTitle { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--color-text-secondary, #475569); + flex: 1; +} + +.sectionCount { + font-size: 11px; + font-weight: 400; + color: var(--color-text-secondary, #94a3b8); +} + +.refreshBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-text-secondary, #94a3b8); + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; + padding: 0; + margin-left: 2px; + flex-shrink: 0; +} + +.refreshBtn:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--color-text, #334155); +} + +/* Filter row */ +.filterRow { + display: flex; + align-items: center; + padding: 4px 8px; + gap: 4px; + position: relative; +} + +.filterInput { + flex: 1; + padding: 4px 24px 4px 8px; + font-size: 12px; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 4px; + background: var(--color-bg, #fff); + color: var(--color-text, #334155); + outline: none; +} + +.filterInput:focus { + border-color: var(--primary-color, #F25843); + box-shadow: 0 0 0 1px var(--primary-color, #F25843); +} + +.filterInput::placeholder { + color: var(--color-text-muted, #94a3b8); +} + +.filterClear { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-size: 14px; + color: var(--color-text-muted, #94a3b8); + padding: 0 2px; + line-height: 1; +} + +.filterClear:hover { + color: var(--color-text, #334155); +} + +/* Tree wrapper */ +.treeWrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 8px; + background: var(--color-bg); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +/* Tree content (scrollable) */ +.treeContent { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +} + +.treeContent::-webkit-scrollbar { + width: 6px; +} + +.treeContent::-webkit-scrollbar-track { + background: transparent; +} + +.treeContent::-webkit-scrollbar-thumb { + background: var(--color-border, #cbd5e1); + border-radius: 3px; +} + +.treeContent::-webkit-scrollbar-thumb:hover { + background: var(--color-text-secondary, #94a3b8); +} + +/* Batch action toolbar */ +.batchToolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--table-header-bg, #edf0f5); + border-bottom: 1px solid var(--color-border, #e2e8f0); + flex-shrink: 0; + flex-wrap: wrap; +} + +.batchCount { + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary, #475569); + margin-right: 4px; + white-space: nowrap; +} + +.batchButton { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-text, #334155); + font-size: 12px; + font-family: var(--font-family); + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.batchButton:hover { + background: var(--color-gray-disabled, #f1f5f9); + border-color: var(--color-text-secondary, #94a3b8); +} + +.batchButtonDanger { + color: #dc2626; + border-color: rgba(220, 38, 38, 0.3); +} + +.batchButtonDanger:hover { + background: rgba(220, 38, 38, 0.06); + border-color: #dc2626; +} + +.batchButtonIcon { + font-size: 12px; + display: inline-flex; +} + +.batchButtonCount { + font-size: 10px; + font-weight: 700; + margin-left: 2px; + opacity: 0.8; +} + +/* Node row */ +.nodeRow { + display: flex; + align-items: center; + gap: 4px; + height: 36px; + padding: 0 8px; + cursor: pointer; + user-select: none; + transition: background-color 0.12s ease; + position: relative; + border-bottom: 1px solid transparent; +} + +.nodeRowCompact { + height: 32px; +} + +.nodeRow:hover { + background: #f0f4ff; +} + +.nodeRowSelected { + background: rgba(var(--color-secondary-rgb), 0.08); +} + +.nodeRowSelected:hover { + background: rgba(var(--color-secondary-rgb), 0.12); +} + +.nodeRowFocused { + box-shadow: inset 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3); + border-radius: 2px; +} + +.nodeRowDragOver { + background: rgba(var(--color-secondary-rgb), 0.06); + border: 1px dashed var(--color-secondary); + border-radius: 4px; +} + +.nodeRowDragging { + opacity: 0.5; +} + +.nodeRowOrphan { + border-left: 2px solid #f59e0b; +} + +/* Indent spacer */ +.indentSpacer { + flex-shrink: 0; +} + +/* Checkbox */ +.nodeCheckbox { + flex-shrink: 0; + width: 14px; + height: 14px; + cursor: pointer; + accent-color: var(--color-secondary); + margin: 0; +} + +/* Expand/collapse chevron */ +.expandChevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 10px; + color: var(--color-text-secondary, #64748b); + cursor: pointer; + flex-shrink: 0; + border-radius: 3px; + transition: transform 0.15s ease, background 0.15s ease; +} + +.expandChevron:hover { + background: rgba(0, 0, 0, 0.06); +} + +.expandChevronExpanded { + transform: rotate(90deg); +} + +.expandChevronPlaceholder { + width: 18px; + flex-shrink: 0; +} + +/* Node icon */ +.nodeIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 14px; + color: var(--color-text-secondary, #64748b); + flex-shrink: 0; +} + +/* Node name */ +.nodeName { + flex: 1; + font-size: 13px; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +/* File size column */ +.nodeSize { + width: 52px; + flex-shrink: 0; + font-size: 10px; + color: var(--color-text-muted, #94a3b8); + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +/* Orphan badge */ +.orphanBadge { + display: inline-flex; + align-items: center; + font-size: 10px; + color: #f59e0b; + margin-left: 2px; + flex-shrink: 0; +} + +/* Inline rename input */ +.renameInput { + flex: 1; + font-size: 13px; + font-family: var(--font-family); + padding: 2px 6px; + border: 1px solid var(--color-secondary); + border-radius: 4px; + background: var(--color-bg); + color: var(--color-text); + outline: none; + box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15); + min-width: 0; +} + +/* Hover action icons (download, delete) -- only visible on hover, left of persistent */ +.nodeActionsHover { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s ease; +} + +.nodeRow:hover .nodeActionsHover { + opacity: 1; +} + +/* Persistent action icons (scope, neutralize) -- always visible, right-aligned */ +.nodeActionsPersistent { + display: flex; + align-items: center; + gap: 0; + flex-shrink: 0; + margin-left: auto; +} + +.nodeActionBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-text-secondary, #94a3b8); + font-size: 14px; + cursor: pointer; + transition: all 0.15s ease; + padding: 0; +} + +.nodeActionBtn:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--color-text, #334155); +} + +.nodeActionBtnDanger:hover { + background: rgba(220, 38, 38, 0.08); + color: #dc2626; +} + +/* Emoji button (scope, neutralize) -- matches SourcesTab style */ +.emojiBtn { + background: none; + border: none; + cursor: pointer; + font-size: 12px; + padding: 0 2px; + line-height: 1; + flex-shrink: 0; + width: 22px; + text-align: center; +} + +.emojiBtnReadonly { + cursor: default; + opacity: 0.35; +} + +/* Loading */ +.loadingState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 16px; + color: var(--color-text-secondary, #64748b); +} + +.loadingSpinner { + width: 24px; + height: 24px; + border: 2px solid var(--color-border, #e2e8f0); + border-top: 2px solid var(--color-text-secondary, #64748b); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-bottom: 8px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.nodeLoadingIndicator { + font-size: 10px; + color: var(--color-text-secondary, #94a3b8); + padding: 4px 0; + padding-left: 24px; +} + +/* Empty state */ +.emptyState { + display: flex; + align-items: center; + justify-content: center; + padding: 32px 16px; +} + +.emptyMessage { + text-align: center; + color: var(--color-text); + opacity: 0.5; + font-size: 13px; + line-height: 1.5; +} + +/* Compact mode */ +.compactMode .sectionHeader { + padding: 6px 8px; +} + +.compactMode .sectionTitle { + font-size: 11px; +} + +.compactMode .treeWrapper { + border: none; + box-shadow: none; +} + +.compactMode .nodeRow { + padding: 0 6px; +} + +.compactMode .nodeName { + font-size: 12px; +} + +/* Dark theme */ +@media (prefers-color-scheme: dark) { + .sectionHeader { + background: #2d3038; + } + + .sectionHeader:hover { + background: #363a42; + } + + .sectionHeaderNonCollapsible:hover { + background: #2d3038; + } + + .nodeRow:hover { + background: rgba(124, 109, 216, 0.08); + } + + .nodeRowSelected { + background: rgba(var(--color-secondary-rgb), 0.15); + } + + .expandChevron:hover { + background: rgba(255, 255, 255, 0.08); + } + + .nodeActionBtn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--color-text, #e2e8f0); + } + + .nodeActionBtnDanger:hover { + background: rgba(220, 38, 38, 0.15); + } + + .batchToolbar { + background: #2d3038; + } + + .batchButton { + background: #363a42; + border-color: rgba(255, 255, 255, 0.1); + color: var(--color-text, #e2e8f0); + } + + .batchButton:hover { + background: #3e424b; + } +} + +/* Responsive */ +@media (max-width: 768px) { + .nodeRow { + height: 36px; + padding: 0 6px; + } + + .nodeName { + font-size: 12px; + } + + .nodeSize { + display: none; + } + + .nodeActionBtn { + width: 24px; + height: 24px; + font-size: 13px; + } + + .emojiBtn { + width: 24px; + font-size: 13px; + } + + .batchToolbar { + padding: 4px 8px; + flex-wrap: wrap; + } + + .batchButton { + padding: 3px 8px; + font-size: 11px; + } + + .filterInput { + font-size: 14px; + padding: 6px 24px 6px 8px; + } + + .sectionHeader { + padding: 8px; + } +} + +/* Touch devices: always show hover actions (no hover on touch) */ +@media (pointer: coarse) { + .nodeActionsHover { + opacity: 1; + } +} + +/* Accessibility */ +.nodeActionBtn:focus-visible, +.expandChevron:focus-visible { + outline: 2px solid var(--color-secondary); + outline-offset: 1px; +} + +.nodeRow:focus-visible { + box-shadow: inset 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3); +} diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx new file mode 100644 index 0000000..aa1bee9 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx @@ -0,0 +1,930 @@ +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { + FaChevronRight, + FaUnlink, + FaSyncAlt, +} from 'react-icons/fa'; +import type { + TreeNode, + TreeNodeProvider, + FormGeneratorTreeProps, + Ownership, + ScopeValue, + TreeBatchAction, +} from './types'; +import styles from './FormGeneratorTree.module.css'; + +const INDENT_PX = 24; +const DRAG_MIME = 'application/x-poweron-tree-items'; + +const SCOPE_ORDER: ScopeValue[] = ['personal', 'featureInstance', 'mandate', 'global']; + +const _SCOPE_EMOJIS: Record = { + personal: '\uD83D\uDC64', + featureInstance: '\uD83D\uDC65', + mandate: '\uD83C\uDFE2', + global: '\uD83C\uDF10', +}; + +const _NEUTRALIZE_EMOJI = '\uD83D\uDD12'; + +function _nextScope(current: ScopeValue | undefined): ScopeValue { + const idx = SCOPE_ORDER.indexOf(current ?? 'personal'); + return SCOPE_ORDER[(idx + 1) % SCOPE_ORDER.length]; +} + +function _formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +interface FlatEntry { + node: TreeNode; + depth: number; + hasChildren: boolean; +} + +function _buildChildMap(nodes: TreeNode[]): Map[]> { + const map = new Map[]>(); + for (const n of nodes) { + const key = n.parentId ?? '__root__'; + const list = map.get(key); + if (list) list.push(n); + else map.set(key, [n]); + } + return map; +} + +function _flatten( + nodes: TreeNode[], + expandedIds: Set, +): FlatEntry[] { + const childMap = _buildChildMap(nodes); + const result: FlatEntry[] = []; + + const _walk = (parentKey: string | '__root__', depth: number) => { + 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'; + result.push({ node, depth, hasChildren }); + if (hasChildren && expandedIds.has(node.id)) { + _walk(node.id, depth + 1); + } + } + }; + + _walk('__root__', 0); + return result; +} + +function _collectDescendantIds(nodeId: string, nodes: TreeNode[]): string[] { + const childMap = _buildChildMap(nodes); + const result: string[] = []; + const _walk = (id: string) => { + const children = childMap.get(id); + if (!children) return; + for (const child of children) { + result.push(child.id); + _walk(child.id); + } + }; + _walk(nodeId); + return result; +} + +interface TreeNodeRowProps { + entry: FlatEntry; + isSelected: boolean; + isFocused: boolean; + isExpanded: boolean; + isRenaming: boolean; + isDragOver: boolean; + isDragging: boolean; + ownership: Ownership; + compact: boolean; + provider: TreeNodeProvider; + onToggleExpand: (id: string) => void; + onToggleSelect: (id: string, e: React.MouseEvent) => void; + onNodeClick: (node: TreeNode) => void; + onStartRename: (id: string) => void; + onConfirmRename: (id: string, newName: string) => void; + onCancelRename: () => void; + onDelete: (id: string) => void; + onDownload: (node: TreeNode) => void; + onSendToChat?: (node: TreeNode) => void; + onCycleScope: (node: TreeNode) => void; + onToggleNeutralize: (node: TreeNode) => void; + onDragStart: (e: React.DragEvent, node: TreeNode) => void; + onDragOver: (e: React.DragEvent, node: TreeNode) => void; + onDragLeave: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent, node: TreeNode) => void; +} + +const TreeNodeRow = React.memo(function TreeNodeRow({ + entry, + isSelected, + isFocused, + isExpanded, + isRenaming, + isDragOver, + isDragging, + ownership, + compact, + provider, + onToggleExpand, + onToggleSelect, + onNodeClick, + onStartRename, + onConfirmRename, + onCancelRename, + onDelete, + onDownload, + onSendToChat, + onCycleScope, + onToggleNeutralize, + onDragStart, + onDragOver, + onDragLeave, + onDrop, +}: TreeNodeRowProps) { + const { node, depth, hasChildren } = entry; + const renameRef = useRef(null); + const [renameValue, setRenameValue] = useState(node.name); + + useEffect(() => { + if (isRenaming && renameRef.current) { + setRenameValue(node.name); + renameRef.current.focus(); + renameRef.current.select(); + } + }, [isRenaming, node.name]); + + const _handleRenameKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + onConfirmRename(node.id, renameValue.trim()); + } else if (e.key === 'Escape') { + e.preventDefault(); + onCancelRename(); + } + }, + [node.id, renameValue, onConfirmRename, onCancelRename], + ); + + const _handleRenameBlur = useCallback(() => { + onConfirmRename(node.id, renameValue.trim()); + }, [node.id, renameValue, onConfirmRename]); + + const _handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (ownership === 'own' && provider.canRename?.(node)) { + onStartRename(node.id); + } + }, + [ownership, provider, node, onStartRename], + ); + + const _handleRowClick = useCallback( + (e: React.MouseEvent) => { + onToggleSelect(node.id, e); + onNodeClick(node); + }, + [node, onToggleSelect, onNodeClick], + ); + + const _handleExpandClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onToggleExpand(node.id); + }, + [node.id, onToggleExpand], + ); + + const isOwn = ownership === 'own'; + const canRename = isOwn && provider.canRename?.(node); + const canDelete = isOwn && provider.canDelete?.(node); + const canPatchScope = isOwn && provider.canPatchScope?.(node); + const canPatchNeutralize = isOwn && provider.canPatchNeutralize?.(node); + + const rowClasses = [ + styles.nodeRow, + compact && styles.nodeRowCompact, + isSelected && styles.nodeRowSelected, + isFocused && styles.nodeRowFocused, + isDragOver && styles.nodeRowDragOver, + isDragging && styles.nodeRowDragging, + node.contextOrphan && styles.nodeRowOrphan, + ] + .filter(Boolean) + .join(' '); + + return ( +
onDragStart(e, node)} + onDragOver={(e) => onDragOver(e, node)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, node)} + data-node-id={node.id} + title={node.name} + role="treeitem" + aria-selected={isSelected} + aria-expanded={hasChildren ? isExpanded : undefined} + tabIndex={-1} + > +
+ + {}} + onClick={(e) => { + e.stopPropagation(); + onToggleSelect(node.id, e as unknown as React.MouseEvent); + }} + tabIndex={-1} + /> + + {hasChildren ? ( + + + + ) : ( + + )} + + {node.icon && {node.icon}} + + {isRenaming ? ( + setRenameValue(e.target.value)} + onKeyDown={_handleRenameKeyDown} + onBlur={_handleRenameBlur} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {node.name} + )} + + {node.contextOrphan && ( + + + + )} + + + {node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''} + + +
+ {canRename && ( + + )} + + {node.type !== 'folder' && ( + + )} + + {canDelete && ( + + )} +
+ +
+ {onSendToChat && ( + + )} + + {node.scope !== undefined && ( + + )} + + {node.neutralize !== undefined && ( + + )} +
+
+ ); +}) as (props: TreeNodeRowProps) => React.ReactElement; + +export function FormGeneratorTree({ + provider, + ownership, + title, + compact = false, + collapsible = false, + defaultCollapsed = false, + emptyMessage, + showFilter = false, + onNodeClick, + onSelectionChange, + onRefresh, + onSendToChat, + className, +}: FormGeneratorTreeProps) { + const [nodes, setNodes] = useState[]>([]); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [renamingId, setRenamingId] = useState(null); + const [loading, setLoading] = useState(true); + const [sectionCollapsed, setSectionCollapsed] = useState(defaultCollapsed); + const [focusedId, setFocusedId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + const [draggingIds, setDraggingIds] = useState>(new Set()); + const [filterText, setFilterText] = useState(''); + const lastSelectedIdRef = useRef(null); + const treeContentRef = useRef(null); + + const _loadRoot = useCallback(async () => { + setLoading(true); + try { + const rootNodes = await provider.loadChildren(null, ownership); + setNodes(rootNodes); + if (defaultCollapsed && rootNodes.length === 0) { + setSectionCollapsed(true); + } + } finally { + setLoading(false); + } + }, [provider, ownership, defaultCollapsed]); + + useEffect(() => { + _loadRoot(); + }, [_loadRoot]); + + const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]); + + const flatEntries = useMemo(() => { + const term = filterText.trim().toLowerCase(); + if (!term) return flatEntriesRaw; + const matchIds = new Set(); + for (const entry of flatEntriesRaw) { + if (entry.node.name.toLowerCase().includes(term)) { + matchIds.add(entry.node.id); + let pid = entry.node.parentId; + while (pid) { + matchIds.add(pid); + const parent = nodes.find((n) => n.id === pid); + pid = parent?.parentId ?? null; + } + } + } + return flatEntriesRaw.filter((e) => matchIds.has(e.node.id)); + }, [flatEntriesRaw, filterText, nodes]); + + const _updateSelection = useCallback( + (newSelection: Set) => { + setSelectedIds(newSelection); + onSelectionChange?.(newSelection); + }, + [onSelectionChange], + ); + + const _handleToggleExpand = useCallback( + async (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + + const node = nodes.find((n) => n.id === id); + if (node && !expandedIds.has(id)) { + 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]); + } + } + } + }, + [nodes, expandedIds, provider, ownership], + ); + + const _handleToggleSelect = useCallback( + (id: string, e: React.MouseEvent) => { + const newSelection = new Set(selectedIds); + + if (e.shiftKey && lastSelectedIdRef.current) { + const visibleIds = flatEntries.map((entry) => entry.node.id); + const lastIdx = visibleIds.indexOf(lastSelectedIdRef.current); + const curIdx = visibleIds.indexOf(id); + if (lastIdx !== -1 && curIdx !== -1) { + const start = Math.min(lastIdx, curIdx); + const end = Math.max(lastIdx, curIdx); + for (let i = start; i <= end; i++) { + newSelection.add(visibleIds[i]); + } + } + } else if (e.ctrlKey || e.metaKey) { + if (newSelection.has(id)) { + newSelection.delete(id); + const descendantIds = _collectDescendantIds(id, nodes); + for (const did of descendantIds) newSelection.delete(did); + } else { + newSelection.add(id); + if (ownership === 'own') { + const descendantIds = _collectDescendantIds(id, nodes); + for (const did of descendantIds) newSelection.add(did); + } + } + } else { + const wasSelected = newSelection.has(id) && newSelection.size === 1; + newSelection.clear(); + if (!wasSelected) { + newSelection.add(id); + if (ownership === 'own') { + const node = nodes.find((n) => n.id === id); + if (node?.type === 'folder') { + const descendantIds = _collectDescendantIds(id, nodes); + for (const did of descendantIds) newSelection.add(did); + } + } + } + } + + lastSelectedIdRef.current = id; + _updateSelection(newSelection); + }, + [selectedIds, flatEntries, nodes, ownership, _updateSelection], + ); + + const _handleNodeClick = useCallback( + (node: TreeNode) => { + setFocusedId(node.id); + onNodeClick?.(node); + }, + [onNodeClick], + ); + + const _handleStartRename = useCallback((id: string) => { + setRenamingId(id); + }, []); + + const _handleConfirmRename = useCallback( + async (id: string, newName: string) => { + setRenamingId(null); + if (!newName) return; + const node = nodes.find((n) => n.id === id); + if (!node || node.name === newName) return; + await provider.renameNode?.(id, newName); + setNodes((prev) => prev.map((n) => (n.id === id ? { ...n, name: newName } : n))); + }, + [nodes, provider], + ); + + const _handleCancelRename = useCallback(() => { + setRenamingId(null); + }, []); + + const _handleRefresh = useCallback(async () => { + await _loadRoot(); + _updateSelection(new Set()); + onRefresh?.(); + }, [_loadRoot, _updateSelection, onRefresh]); + + const _handleDelete = useCallback( + async (id: string) => { + const node = nodes.find((n) => n.id === id); + const label = node?.name ?? id; + if (!window.confirm(`"${label}" wirklich loeschen?`)) return; + await provider.deleteNodes?.([id]); + setNodes((prev) => { + const toRemove = new Set([id, ..._collectDescendantIds(id, prev)]); + return prev.filter((n) => !toRemove.has(n.id)); + }); + _updateSelection(new Set([...selectedIds].filter((sid) => sid !== id))); + }, + [provider, selectedIds, nodes, _updateSelection], + ); + + const _handleDownload = useCallback( + async (node: TreeNode) => { + await provider.downloadNode?.(node); + }, + [provider], + ); + + const _handleCycleScope = useCallback( + async (node: TreeNode) => { + const newScope = _nextScope(node.scope); + await provider.patchScope?.([node.id], newScope); + setNodes((prev) => + prev.map((n) => (n.id === node.id ? { ...n, scope: newScope } : n)), + ); + }, + [provider], + ); + + const _handleToggleNeutralize = useCallback( + async (node: TreeNode) => { + const newValue = !node.neutralize; + await provider.patchNeutralize?.([node.id], newValue); + setNodes((prev) => + prev.map((n) => (n.id === node.id ? { ...n, neutralize: newValue } : n)), + ); + }, + [provider], + ); + + const _handleDragStart = useCallback( + (e: React.DragEvent, node: TreeNode) => { + const dragIds = selectedIds.has(node.id) ? [...selectedIds] : [node.id]; + const payload = dragIds.map((id) => { + const n = nodes.find((nd) => nd.id === id); + return { id, type: n?.type ?? '', name: n?.name ?? '', providerKey: provider.rootKey }; + }); + e.dataTransfer.setData(DRAG_MIME, JSON.stringify(payload)); + + const chatPayload = dragIds.map((id) => { + const n = nodes.find((nd) => nd.id === id); + return { id, type: n?.type === 'folder' ? 'group' : 'file', name: n?.name ?? '' }; + }); + e.dataTransfer.setData('application/tree-items', JSON.stringify(chatPayload)); + e.dataTransfer.setData('text/plain', chatPayload.map((p) => p.name).join(', ')); + + e.dataTransfer.effectAllowed = 'copyMove'; + setDraggingIds(new Set(dragIds)); + }, + [selectedIds, nodes, provider.rootKey], + ); + + const _handleDragOver = useCallback( + (e: React.DragEvent, node: TreeNode) => { + if (ownership === 'shared') return; + if (draggingIds.size === 0) return; + if (draggingIds.has(node.id)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverId(node.id); + }, + [ownership, draggingIds], + ); + + const _handleDragLeave = useCallback(() => { + setDragOverId(null); + }, []); + + const _handleDrop = useCallback( + async (e: React.DragEvent, targetNode: TreeNode) => { + setDragOverId(null); + + const raw = e.dataTransfer.getData(DRAG_MIME); + if (!raw) return; + + e.preventDefault(); + e.stopPropagation(); + setDraggingIds(new Set()); + + if (ownership === 'shared') return; + + try { + const items = JSON.parse(raw) as { id: string }[]; + const ids = items.map((it) => it.id); + + const canMove = ids.every((id) => { + const sourceNode = nodes.find((n) => n.id === id); + return sourceNode && provider.canMove?.(sourceNode, targetNode); + }); + + if (!canMove) return; + + await provider.moveNodes?.(ids, targetNode.id); + setNodes((prev) => + prev.map((n) => (ids.includes(n.id) ? { ...n, parentId: targetNode.id } : n)), + ); + } catch { + // invalid drag payload + } + }, + [ownership, nodes, provider], + ); + + const _handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!focusedId) return; + const idx = flatEntries.findIndex((entry) => entry.node.id === focusedId); + if (idx === -1) return; + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + if (idx < flatEntries.length - 1) { + const nextId = flatEntries[idx + 1].node.id; + setFocusedId(nextId); + _scrollToNode(nextId); + } + break; + } + case 'ArrowUp': { + e.preventDefault(); + if (idx > 0) { + const prevId = flatEntries[idx - 1].node.id; + setFocusedId(prevId); + _scrollToNode(prevId); + } + break; + } + case 'ArrowRight': { + e.preventDefault(); + const entry = flatEntries[idx]; + if (entry.hasChildren && !expandedIds.has(entry.node.id)) { + _handleToggleExpand(entry.node.id); + } + break; + } + case 'ArrowLeft': { + e.preventDefault(); + const entry = flatEntries[idx]; + if (expandedIds.has(entry.node.id)) { + _handleToggleExpand(entry.node.id); + } else if (entry.node.parentId) { + setFocusedId(entry.node.parentId); + _scrollToNode(entry.node.parentId); + } + break; + } + case 'Enter': { + e.preventDefault(); + _handleToggleSelect(focusedId, e as unknown as React.MouseEvent); + break; + } + case 'F2': { + e.preventDefault(); + const node = nodes.find((n) => n.id === focusedId); + if (node && ownership === 'own' && provider.canRename?.(node)) { + _handleStartRename(focusedId); + } + break; + } + case 'Delete': { + e.preventDefault(); + const node = nodes.find((n) => n.id === focusedId); + if (node && ownership === 'own' && provider.canDelete?.(node)) { + _handleDelete(focusedId); + } + break; + } + } + }, + [ + focusedId, + flatEntries, + expandedIds, + nodes, + ownership, + provider, + _handleToggleExpand, + _handleToggleSelect, + _handleStartRename, + _handleDelete, + ], + ); + + const _scrollToNode = useCallback((nodeId: string) => { + const el = treeContentRef.current?.querySelector(`[data-node-id="${nodeId}"]`); + el?.scrollIntoView({ block: 'nearest' }); + }, []); + + const batchActions = useMemo(() => { + const actions = provider.getBatchActions?.() ?? []; + return actions.filter( + (a: TreeBatchAction) => !a.ownershipFilter || a.ownershipFilter === ownership, + ); + }, [provider, ownership]); + + const _filteredIdsForAction = useCallback( + (action: TreeBatchAction): string[] => { + const ids = [...selectedIds]; + if (!action.typeFilter) return ids; + return ids.filter((id) => { + const node = nodes.find((n) => n.id === id); + return node?.type === action.typeFilter; + }); + }, + [selectedIds, nodes], + ); + + const totalNodeCount = nodes.filter((n) => n.parentId === null).length; + + const wrapperClasses = [ + styles.formGeneratorTree, + compact && styles.compactMode, + className, + ] + .filter(Boolean) + .join(' '); + + return ( +
+ {title && ( +
setSectionCollapsed((v) => !v) : undefined} + > + {collapsible && ( + + + + )} + {title} + {totalNodeCount} + +
+ )} + + {(!collapsible || !sectionCollapsed) && ( +
+ {showFilter && ( +
+ setFilterText(e.target.value)} + /> + {filterText && ( + + )} +
+ )} + + {selectedIds.size > 0 && batchActions.length > 0 && ( +
+ {selectedIds.size} selected + {batchActions.map((action: TreeBatchAction) => { + const ids = _filteredIdsForAction(action); + if (ids.length === 0) return null; + return ( + + ); + })} +
+ )} + +
+ {loading ? ( +
+
+
+ ) : flatEntries.length === 0 ? ( +
+ + {emptyMessage ?? 'No items'} + +
+ ) : ( + flatEntries.map((entry) => ( + + key={entry.node.id} + entry={entry} + isSelected={selectedIds.has(entry.node.id)} + isFocused={focusedId === entry.node.id} + isExpanded={expandedIds.has(entry.node.id)} + isRenaming={renamingId === entry.node.id} + isDragOver={dragOverId === entry.node.id} + isDragging={draggingIds.has(entry.node.id)} + ownership={ownership} + compact={compact} + provider={provider} + onToggleExpand={_handleToggleExpand} + onToggleSelect={_handleToggleSelect} + onNodeClick={_handleNodeClick} + onStartRename={_handleStartRename} + onConfirmRename={_handleConfirmRename} + onCancelRename={_handleCancelRename} + onDelete={_handleDelete} + onDownload={_handleDownload} + onSendToChat={onSendToChat} + onCycleScope={_handleCycleScope} + onToggleNeutralize={_handleToggleNeutralize} + onDragStart={_handleDragStart} + onDragOver={_handleDragOver} + onDragLeave={_handleDragLeave} + onDrop={_handleDrop} + /> + )) + )} +
+
+ )} +
+ ); +} + +export default FormGeneratorTree; diff --git a/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx b/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx new file mode 100644 index 0000000..1835c9e --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorTree/__tests__/FormGeneratorTree.test.tsx @@ -0,0 +1,683 @@ +// Copyright (c) 2026 Patrick Motsch +// All rights reserved. + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormGeneratorTree } from '../FormGeneratorTree'; +import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const _ownFolder: TreeNode = { + id: 'f1', + name: 'My Folder', + type: 'folder', + parentId: null, + ownership: 'own', + scope: 'personal', + neutralize: false, +}; + +const _ownFile: TreeNode = { + id: 'file1', + name: 'doc.pdf', + type: 'file', + parentId: 'f1', + ownership: 'own', + scope: 'personal', + neutralize: false, +}; + +const _sharedFolder: TreeNode = { + id: 'sf1', + name: 'Shared Folder', + type: 'folder', + parentId: null, + ownership: 'shared', + scope: 'mandate', + neutralize: false, +}; + +const _orphanFile: TreeNode = { + id: 'of1', + name: 'orphan.txt', + type: 'file', + parentId: null, + ownership: 'shared', + scope: 'mandate', + contextOrphan: true, +}; + +// --------------------------------------------------------------------------- +// Mock Provider Factory +// --------------------------------------------------------------------------- + +function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider { + return { + rootKey: 'test', + loadChildren: vi.fn(async (parentId) => + nodes.filter((n) => n.parentId === parentId), + ), + canCreate: vi.fn(() => true), + canRename: vi.fn((node) => node.ownership === 'own'), + canDelete: vi.fn((node) => node.ownership === 'own'), + canMove: vi.fn(() => true), + canPatchScope: vi.fn((node) => node.ownership === 'own'), + canPatchNeutralize: vi.fn((node) => node.ownership === 'own'), + createChild: vi.fn(async (parentId, name) => ({ + id: 'new-1', + name, + type: 'folder', + parentId, + ownership: 'own' as const, + scope: 'personal' as const, + })), + renameNode: vi.fn(async () => {}), + deleteNodes: vi.fn(async () => {}), + moveNodes: vi.fn(async () => {}), + patchScope: vi.fn(async () => {}), + patchNeutralize: vi.fn(async () => {}), + getBatchActions: vi.fn(() => []), + }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('FormGeneratorTree', () => { + describe('Rendering', () => { + it('renders tree with title and node count', async () => { + const provider = _createMockProvider([_ownFolder]); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Documents')).toBeInTheDocument(); + }); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('shows loading spinner while loading', () => { + const provider = _createMockProvider([]); + provider.loadChildren = vi.fn(() => new Promise(() => {})); // never resolves + render(); + + const tree = screen.getByRole('tree'); + expect(tree.querySelector('[class*="loadingSpinner"]')).toBeInTheDocument(); + }); + + it('shows empty message when no nodes', async () => { + const provider = _createMockProvider([]); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Nothing here')).toBeInTheDocument(); + }); + }); + + it('shows default empty message when no custom message', async () => { + const provider = _createMockProvider([]); + render(); + + await waitFor(() => { + expect(screen.getByText('No items')).toBeInTheDocument(); + }); + }); + + it('renders nodes with correct names', async () => { + const provider = _createMockProvider([_ownFolder, _sharedFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + expect(screen.getByText('Shared Folder')).toBeInTheDocument(); + }); + + it('renders nested nodes with indentation when folder expanded', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder, _ownFile]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + const expandBtn = screen.getByRole('treeitem', { name: /My Folder/i }) + .querySelector('[role="button"]')!; + await user.click(expandBtn); + + await waitFor(() => { + expect(screen.getByText('doc.pdf')).toBeInTheDocument(); + }); + }); + + it('shows contextOrphan badge for orphan nodes', async () => { + const provider = _createMockProvider([_orphanFile]); + render(); + + await waitFor(() => { + expect(screen.getByText('orphan.txt')).toBeInTheDocument(); + }); + expect(screen.getByTitle('Context orphan')).toBeInTheDocument(); + }); + }); + + // --------------------------------------------------------------------------- + // Selection + // --------------------------------------------------------------------------- + + describe('Selection', () => { + it('click selects a node', async () => { + const user = userEvent.setup(); + const onSelectionChange = vi.fn(); + const provider = _createMockProvider([_ownFolder]); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('treeitem', { name: /My Folder/i })); + + expect(onSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ has: expect.any(Function) }), + ); + const lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set; + expect(lastCall.has('f1')).toBe(true); + }); + + it('ctrl+click adds to selection', async () => { + const user = userEvent.setup(); + const onSelectionChange = vi.fn(); + const secondNode: TreeNode = { + id: 'f2', + name: 'Other Folder', + type: 'folder', + parentId: null, + ownership: 'own', + scope: 'personal', + }; + const provider = _createMockProvider([_ownFolder, secondNode]); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('treeitem', { name: /My Folder/i })); + await user.click(screen.getByRole('treeitem', { name: /Other Folder/i }), { + ctrlKey: true, + }); + + const lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set; + expect(lastCall.has('f1')).toBe(true); + expect(lastCall.has('f2')).toBe(true); + }); + + it('click on selected folder cascades deselect of descendants (own)', async () => { + const user = userEvent.setup(); + const onSelectionChange = vi.fn(); + const provider = _createMockProvider([_ownFolder, _ownFile]); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + // Expand folder first + const expandBtn = screen.getByRole('treeitem', { name: /My Folder/i }) + .querySelector('[role="button"]')!; + await user.click(expandBtn); + + await waitFor(() => { + expect(screen.getByText('doc.pdf')).toBeInTheDocument(); + }); + + // Select folder (cascades to children in own mode) + await user.click(screen.getByRole('treeitem', { name: /My Folder/i })); + + let lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set; + expect(lastCall.has('f1')).toBe(true); + expect(lastCall.has('file1')).toBe(true); + + // Click again to deselect + await user.click(screen.getByRole('treeitem', { name: /My Folder/i })); + + lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set; + expect(lastCall.has('f1')).toBe(false); + expect(lastCall.has('file1')).toBe(false); + }); + + it('selection in shared tree does NOT cascade to children', async () => { + const user = userEvent.setup(); + const onSelectionChange = vi.fn(); + const sharedChild: TreeNode = { + id: 'sc1', + name: 'child.txt', + type: 'file', + parentId: 'sf1', + ownership: 'shared', + scope: 'mandate', + }; + const provider = _createMockProvider([_sharedFolder, sharedChild]); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Shared Folder')).toBeInTheDocument(); + }); + + // Expand folder + const expandBtn = screen.getByRole('treeitem', { name: /Shared Folder/i }) + .querySelector('[role="button"]')!; + await user.click(expandBtn); + + await waitFor(() => { + expect(screen.getByText('child.txt')).toBeInTheDocument(); + }); + + // Click folder in shared mode + await user.click(screen.getByRole('treeitem', { name: /Shared Folder/i })); + + const lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set; + expect(lastCall.has('sf1')).toBe(true); + expect(lastCall.has('sc1')).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Expand/Collapse + // --------------------------------------------------------------------------- + + describe('Expand/Collapse', () => { + it('clicking chevron expands folder and loads children lazily', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder, _ownFile]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument(); + + const row = screen.getByRole('treeitem', { name: /My Folder/i }); + const expandBtn = row.querySelector('[role="button"]')!; + await user.click(expandBtn); + + await waitFor(() => { + expect(screen.getByText('doc.pdf')).toBeInTheDocument(); + }); + + expect(provider.loadChildren).toHaveBeenCalledWith('f1', 'own'); + }); + + it('clicking expanded folder collapses it', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder, _ownFile]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + const row = screen.getByRole('treeitem', { name: /My Folder/i }); + const expandBtn = row.querySelector('[role="button"]')!; + + // Expand + await user.click(expandBtn); + await waitFor(() => { + expect(screen.getByText('doc.pdf')).toBeInTheDocument(); + }); + + // Collapse + await user.click(expandBtn); + await waitFor(() => { + expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument(); + }); + }); + }); + + // --------------------------------------------------------------------------- + // Inline Rename + // --------------------------------------------------------------------------- + + describe('Inline Rename', () => { + it('double-click on own node starts rename', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + await user.dblClick(screen.getByRole('treeitem', { name: /My Folder/i })); + + await waitFor(() => { + expect(screen.getByDisplayValue('My Folder')).toBeInTheDocument(); + }); + }); + + it('enter confirms rename and calls provider.renameNode', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + await user.dblClick(screen.getByRole('treeitem', { name: /My Folder/i })); + + const input = await screen.findByDisplayValue('My Folder'); + await user.clear(input); + await user.type(input, 'Renamed{Enter}'); + + await waitFor(() => { + expect(provider.renameNode).toHaveBeenCalledWith('f1', 'Renamed'); + }); + }); + + it('escape cancels rename', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + await user.dblClick(screen.getByRole('treeitem', { name: /My Folder/i })); + + const input = await screen.findByDisplayValue('My Folder'); + await user.type(input, '{Escape}'); + + await waitFor(() => { + expect(screen.queryByDisplayValue('My Folder')).not.toBeInTheDocument(); + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + expect(provider.renameNode).not.toHaveBeenCalled(); + }); + + it('double-click on shared node does NOT start rename', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_sharedFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('Shared Folder')).toBeInTheDocument(); + }); + + await user.dblClick(screen.getByRole('treeitem', { name: /Shared Folder/i })); + + expect(screen.queryByDisplayValue('Shared Folder')).not.toBeInTheDocument(); + }); + }); + + // --------------------------------------------------------------------------- + // Delete + // --------------------------------------------------------------------------- + + describe('Delete', () => { + it('delete button calls provider.deleteNodes', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + const row = screen.getByRole('treeitem', { name: /My Folder/i }); + const deleteBtn = within(row).getByTitle('Delete'); + await user.click(deleteBtn); + + await waitFor(() => { + expect(provider.deleteNodes).toHaveBeenCalledWith(['f1']); + }); + }); + + it('no delete button shown for shared nodes', async () => { + const provider = _createMockProvider([_sharedFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('Shared Folder')).toBeInTheDocument(); + }); + + const row = screen.getByRole('treeitem', { name: /Shared Folder/i }); + expect(within(row).queryByTitle('Delete')).not.toBeInTheDocument(); + }); + }); + + // --------------------------------------------------------------------------- + // Scope Cycling + // --------------------------------------------------------------------------- + + describe('Scope Cycling', () => { + it('clicking scope icon cycles through values', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + const scopeBtn = screen.getByTitle('Scope: personal'); + await user.click(scopeBtn); + + await waitFor(() => { + expect(provider.patchScope).toHaveBeenCalledWith( + ['f1'], + 'featureInstance', + ); + }); + }); + + it('scope icon is readonly in shared tree', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_sharedFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('Shared Folder')).toBeInTheDocument(); + }); + + const scopeBtn = screen.getByTitle('Scope: mandate'); + await user.click(scopeBtn); + + expect(provider.patchScope).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // Neutralize Toggle + // --------------------------------------------------------------------------- + + describe('Neutralize Toggle', () => { + it('clicking neutralize icon toggles value', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + const neutralizeBtn = screen.getByTitle('Not neutralized'); + await user.click(neutralizeBtn); + + await waitFor(() => { + expect(provider.patchNeutralize).toHaveBeenCalledWith(['f1'], true); + }); + }); + + it('neutralize icon is readonly in shared tree', async () => { + const user = userEvent.setup(); + const sharedNodeWithNeutralize: TreeNode = { + ..._sharedFolder, + neutralize: false, + }; + const provider = _createMockProvider([sharedNodeWithNeutralize]); + render(); + + await waitFor(() => { + expect(screen.getByText('Shared Folder')).toBeInTheDocument(); + }); + + const neutralizeBtn = screen.getByTitle('Not neutralized'); + await user.click(neutralizeBtn); + + expect(provider.patchNeutralize).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // Collapsible Section + // --------------------------------------------------------------------------- + + describe('Collapsible Section', () => { + it('section collapses when clicking header', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder]); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Documents')); + + await waitFor(() => { + expect(screen.queryByText('My Folder')).not.toBeInTheDocument(); + }); + }); + + it('section expands when clicking header again', async () => { + const user = userEvent.setup(); + const provider = _createMockProvider([_ownFolder]); + render( + , + ); + + // Initially collapsed + expect(screen.queryByRole('tree')).not.toBeInTheDocument(); + + await user.click(screen.getByText('Documents')); + + await waitFor(() => { + expect(screen.getByRole('tree')).toBeInTheDocument(); + }); + }); + }); + + // --------------------------------------------------------------------------- + // Batch Actions + // --------------------------------------------------------------------------- + + describe('Batch Actions', () => { + it('batch toolbar appears when items selected', async () => { + const user = userEvent.setup(); + const batchAction: TreeBatchAction = { + key: 'export', + label: 'Export', + onClick: vi.fn(), + }; + const provider = _createMockProvider([_ownFolder]); + provider.getBatchActions = vi.fn(() => [batchAction]); + + render(); + + await waitFor(() => { + expect(screen.getByText('My Folder')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Export')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('treeitem', { name: /My Folder/i })); + + await waitFor(() => { + expect(screen.getByText(/selected/i)).toBeInTheDocument(); + expect(screen.getByText('Export')).toBeInTheDocument(); + }); + }); + + it('batch actions filtered by ownership', async () => { + const user = userEvent.setup(); + const ownOnlyAction: TreeBatchAction = { + key: 'delete-all', + label: 'Delete All', + danger: true, + ownershipFilter: 'own', + onClick: vi.fn(), + }; + const provider = _createMockProvider([_sharedFolder]); + provider.getBatchActions = vi.fn(() => [ownOnlyAction]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Shared Folder')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('treeitem', { name: /Shared Folder/i })); + + // The action has ownershipFilter='own' but we're in 'shared' mode, so it's filtered out + await waitFor(() => { + const lastCall = provider.getBatchActions as ReturnType; + expect(lastCall).toHaveBeenCalled(); + }); + expect(screen.queryByText('Delete All')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx b/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx new file mode 100644 index 0000000..d40ff0c --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorTree/__tests__/dnd.test.tsx @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Patrick Motsch +// All rights reserved. + +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { FormGeneratorTree } from '../FormGeneratorTree'; +import type { TreeNode, TreeNodeProvider } from '../types'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const _ownFolder: TreeNode = { + id: 'f1', + name: 'Target Folder', + type: 'folder', + parentId: null, + ownership: 'own', + scope: 'personal', + neutralize: false, +}; + +const _ownFile: TreeNode = { + id: 'file1', + name: 'doc.pdf', + type: 'file', + parentId: null, + ownership: 'own', + scope: 'personal', + neutralize: false, +}; + +const _sharedFolder: TreeNode = { + id: 'sf1', + name: 'Shared Folder', + type: 'folder', + parentId: null, + ownership: 'shared', + scope: 'mandate', + neutralize: false, +}; + +const _sharedFile: TreeNode = { + id: 'sfile1', + name: 'shared.pdf', + type: 'file', + parentId: null, + ownership: 'shared', + scope: 'mandate', + neutralize: false, +}; + +// --------------------------------------------------------------------------- +// Mock Provider Factory +// --------------------------------------------------------------------------- + +function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider { + return { + rootKey: 'test', + loadChildren: vi.fn(async (parentId) => + nodes.filter((n) => n.parentId === parentId), + ), + canCreate: vi.fn(() => true), + canRename: vi.fn((node) => node.ownership === 'own'), + canDelete: vi.fn((node) => node.ownership === 'own'), + canMove: vi.fn(() => true), + canPatchScope: vi.fn((node) => node.ownership === 'own'), + canPatchNeutralize: vi.fn((node) => node.ownership === 'own'), + createChild: vi.fn(async (parentId, name) => ({ + id: 'new-1', + name, + type: 'folder', + parentId, + ownership: 'own' as const, + scope: 'personal' as const, + })), + renameNode: vi.fn(async () => {}), + deleteNodes: vi.fn(async () => {}), + moveNodes: vi.fn(async () => {}), + patchScope: vi.fn(async () => {}), + patchNeutralize: vi.fn(async () => {}), + getBatchActions: vi.fn(() => []), + }; +} + +// --------------------------------------------------------------------------- +// Drag and Drop Helpers +// --------------------------------------------------------------------------- + +function _createDataTransfer(data: Record = {}): DataTransfer { + const store: Record = { ...data }; + return { + setData: vi.fn((type: string, val: string) => { + store[type] = val; + }), + getData: vi.fn((type: string) => store[type] ?? ''), + effectAllowed: 'uninitialized', + dropEffect: 'none', + clearData: vi.fn(), + items: [] as unknown as DataTransferItemList, + types: Object.keys(store), + files: [] as unknown as FileList, + setDragImage: vi.fn(), + } as unknown as DataTransfer; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FormGeneratorTree - Drag and Drop', () => { + it('drag start sets MIME application/x-poweron-tree-items with correct payload', async () => { + const provider = _createMockProvider([_ownFile, _ownFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('doc.pdf')).toBeInTheDocument(); + }); + + const row = screen.getByRole('treeitem', { name: /doc\.pdf/i }); + const dataTransfer = _createDataTransfer(); + + fireEvent.dragStart(row, { dataTransfer }); + + expect(dataTransfer.setData).toHaveBeenCalledWith( + 'application/x-poweron-tree-items', + expect.any(String), + ); + + const payload = JSON.parse( + (dataTransfer.setData as ReturnType).mock.calls[0][1], + ); + expect(payload).toEqual([ + { + id: 'file1', + type: 'file', + name: 'doc.pdf', + providerKey: 'test', + }, + ]); + }); + + it('drop on folder calls provider.moveNodes', async () => { + const provider = _createMockProvider([_ownFile, _ownFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('doc.pdf')).toBeInTheDocument(); + }); + + const targetRow = screen.getByRole('treeitem', { name: /Target Folder/i }); + const dragPayload = JSON.stringify([ + { id: 'file1', type: 'file', name: 'doc.pdf', providerKey: 'test' }, + ]); + const dataTransfer = _createDataTransfer({ + 'application/x-poweron-tree-items': dragPayload, + }); + + fireEvent.dragOver(targetRow, { dataTransfer }); + fireEvent.drop(targetRow, { dataTransfer }); + + await waitFor(() => { + expect(provider.moveNodes).toHaveBeenCalledWith(['file1'], 'f1'); + }); + }); + + it('drop in shared tree is blocked (no move call)', async () => { + const provider = _createMockProvider([_sharedFile, _sharedFolder]); + render(); + + await waitFor(() => { + expect(screen.getByText('shared.pdf')).toBeInTheDocument(); + }); + + const targetRow = screen.getByRole('treeitem', { name: /Shared Folder/i }); + const dragPayload = JSON.stringify([ + { id: 'sfile1', type: 'file', name: 'shared.pdf', providerKey: 'test' }, + ]); + const dataTransfer = _createDataTransfer({ + 'application/x-poweron-tree-items': dragPayload, + }); + + fireEvent.dragOver(targetRow, { dataTransfer }); + fireEvent.drop(targetRow, { dataTransfer }); + + // In shared tree, the dragOver handler returns early without calling preventDefault + // so drop won't trigger moveNodes + expect(provider.moveNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/FormGenerator/FormGeneratorTree/index.ts b/src/components/FormGenerator/FormGeneratorTree/index.ts new file mode 100644 index 0000000..8792d9b --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorTree/index.ts @@ -0,0 +1,9 @@ +export { FormGeneratorTree } from './FormGeneratorTree'; +export type { + TreeNode, + TreeNodeProvider, + TreeBatchAction, + FormGeneratorTreeProps, + Ownership, + ScopeValue, +} from './types'; diff --git a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx new file mode 100644 index 0000000..ad8899b --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx @@ -0,0 +1,261 @@ +import { FaFolder, FaFile, FaTrash } from 'react-icons/fa'; +import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types'; +import api from '../../../../api'; +import { getUserDataCache } from '../../../../utils/userCache'; + +interface FolderData { + id: string; + name: string; + parentId?: string | null; + scope?: ScopeValue; + neutralize?: boolean; + contextOrphan?: boolean; +} + +interface FileData { + id: string; + fileName: string; + folderId?: string | null; + fileSize?: number; + scope?: ScopeValue; + neutralize?: boolean; + contextOrphan?: boolean; + sysCreatedBy?: string; +} + +function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode { + return { + id: folder.id, + name: folder.name, + type: 'folder', + parentId: folder.parentId ?? null, + ownership, + scope: folder.scope, + neutralize: folder.neutralize, + contextOrphan: folder.contextOrphan, + icon: , + }; +} + +function _mapFileToNode(file: FileData, ownership: Ownership): TreeNode { + return { + id: file.id, + name: file.fileName, + type: 'file', + parentId: file.folderId ?? null, + ownership, + scope: file.scope, + neutralize: file.neutralize, + contextOrphan: file.contextOrphan, + sizeBytes: file.fileSize, + icon: , + }; +} + +export function createFolderFileProvider(): TreeNodeProvider { + const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared'); + const typeMap = new Map(); + + function _trackTypes(nodes: TreeNode[]) { + for (const n of nodes) { + typeMap.set(n.id, n.type as 'folder' | 'file'); + } + } + + function _isFile(id: string): boolean { + return typeMap.get(id) === 'file'; + } + + return { + rootKey: 'files', + + async loadChildren(parentId, ownership) { + const owner = ownerParam(ownership); + const nodes: TreeNode[] = []; + + 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))); + + try { + const filters: Record = {}; + if (parentId) { + filters.folderId = parentId; + } + const paginationParam = JSON.stringify({ filters, pageSize: 500 }); + const filesRes = await api.get('/api/files/list', { + params: { pagination: paginationParam }, + }); + const data = filesRes.data; + let rawFiles: FileData[] = []; + if (data && typeof data === 'object' && 'items' in data) { + rawFiles = Array.isArray(data.items) ? data.items : []; + } else if (Array.isArray(data)) { + rawFiles = data; + } + let matched = rawFiles.filter((f) => (f.folderId ?? null) === parentId); + if (ownership === 'shared') { + const myId = getUserDataCache()?.id; + if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId); + } + nodes.push(...matched.map((f) => _mapFileToNode(f, ownership))); + } catch { + // file list may fail for shared trees; folders still render + } + + _trackTypes(nodes); + return nodes; + }, + + canCreate() { + return true; + }, + + canRename(node) { + return node.ownership === 'own'; + }, + + canDelete(node) { + return node.ownership === 'own'; + }, + + canMove(source, target) { + if (source.ownership !== 'own') return false; + if (target && target.type !== 'folder') return false; + if (target && target.id === source.id) return false; + return true; + }, + + canPatchScope(node) { + return node.ownership === 'own'; + }, + + canPatchNeutralize(node) { + return node.ownership === 'own'; + }, + + async createChild(parentId, name) { + const res = await api.post('/api/files/folders', { name, parentId }); + return _mapFolderToNode(res.data, 'own'); + }, + + async renameNode(id, newName) { + if (_isFile(id)) { + await api.put(`/api/files/${id}`, { fileName: newName }); + } else { + await api.patch(`/api/files/folders/${id}`, { name: newName }); + } + }, + + async deleteNodes(ids) { + await Promise.all(ids.map((id) => { + if (_isFile(id)) return api.delete(`/api/files/${id}`); + return api.delete(`/api/files/folders/${id}`); + })); + }, + + async moveNodes(ids, targetParentId) { + 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 }); + }), + ); + }, + + async patchScope(ids, scope, cascadeChildren) { + await Promise.all( + ids.map((id) => { + if (_isFile(id)) return api.patch(`/api/files/${id}/scope`, { scope }); + return api.patch(`/api/files/folders/${id}/scope`, { scope, cascadeChildren }); + }), + ); + }, + + async downloadNode(node) { + if (node.type === 'folder') return; + const res = await api.get(`/api/files/${node.id}/download`, { responseType: 'blob' }); + const url = window.URL.createObjectURL(res.data); + const a = document.createElement('a'); + a.href = url; + a.download = node.name; + a.click(); + window.URL.revokeObjectURL(url); + }, + + async patchNeutralize(ids, neutralize) { + await Promise.all( + ids.map((id) => { + if (_isFile(id)) return api.patch(`/api/files/${id}/neutralize`, { neutralize }); + return api.patch(`/api/files/folders/${id}/neutralize`, { neutralize }); + }), + ); + }, + + getBatchActions(): TreeBatchAction[] { + return [ + { + key: 'delete-folders', + label: 'Ordner', + icon: <>, + danger: true, + ownershipFilter: 'own', + typeFilter: 'folder', + async onClick(folderIds) { + await Promise.all(folderIds.map((id) => api.delete(`/api/files/folders/${id}`))); + }, + }, + { + key: 'delete-files', + label: 'Dateien', + icon: , + danger: true, + ownershipFilter: 'own', + typeFilter: 'file', + async onClick(fileIds) { + if (fileIds.length === 1) { + await api.delete(`/api/files/${fileIds[0]}`); + } else { + await api.post('/api/files/batch-delete', { fileIds }); + } + }, + }, + { + key: 'download', + label: 'Download', + async onClick(selectedIds) { + const folderIds = selectedIds.filter((id) => typeMap.get(id) === 'folder'); + const fileIds = selectedIds.filter((id) => typeMap.get(id) !== 'folder'); + + if (fileIds.length === 1 && folderIds.length === 0) { + const res = await api.get(`/api/files/${fileIds[0]}/download`, { responseType: 'blob' }); + const url = window.URL.createObjectURL(res.data); + const a = document.createElement('a'); + a.href = url; + const disposition = res.headers?.['content-disposition'] ?? ''; + const match = disposition.match(/filename\*?=(?:UTF-8'')?(.+)/i); + a.download = match ? decodeURIComponent(match[1]) : fileIds[0]; + a.click(); + window.URL.revokeObjectURL(url); + } else { + const res = await api.post( + '/api/files/batch-download', + { fileIds, folderIds }, + { responseType: 'blob' }, + ); + const url = window.URL.createObjectURL(res.data); + const a = document.createElement('a'); + a.href = url; + a.download = 'download.zip'; + a.click(); + window.URL.revokeObjectURL(url); + } + }, + }, + ]; + }, + }; +} + +export default createFolderFileProvider; diff --git a/src/components/FormGenerator/FormGeneratorTree/types.ts b/src/components/FormGenerator/FormGeneratorTree/types.ts new file mode 100644 index 0000000..12c1d47 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorTree/types.ts @@ -0,0 +1,64 @@ +export type Ownership = 'own' | 'shared'; + +export type ScopeValue = 'personal' | 'featureInstance' | 'mandate' | 'global'; + +export interface TreeNode { + id: string; + name: string; + type: string; + parentId: string | null; + ownership: Ownership; + scope?: ScopeValue; + neutralize?: boolean; + contextOrphan?: boolean; + icon?: React.ReactNode; + children?: TreeNode[]; + isLoading?: boolean; + sizeBytes?: number; + data?: T; +} + +export interface TreeBatchAction { + key: string; + label: string; + icon?: React.ReactNode; + danger?: boolean; + ownershipFilter?: Ownership; + typeFilter?: string; + onClick: (selectedIds: string[]) => void | Promise; +} + +export interface TreeNodeProvider { + rootKey: string; + loadChildren(parentId: string | null, ownership: Ownership): Promise[]>; + canCreate?(parentId: string | null): boolean; + canRename?(node: TreeNode): boolean; + canDelete?(node: TreeNode): boolean; + canMove?(source: TreeNode, target: TreeNode | null): boolean; + canPatchScope?(node: TreeNode): boolean; + canPatchNeutralize?(node: TreeNode): boolean; + createChild?(parentId: string | null, name: string): Promise>; + renameNode?(id: string, newName: string): Promise; + deleteNodes?(ids: string[]): Promise; + moveNodes?(ids: string[], targetParentId: string | null): Promise; + patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise; + patchNeutralize?(ids: string[], neutralize: boolean): Promise; + downloadNode?(node: TreeNode): Promise; + getBatchActions?(): TreeBatchAction[]; +} + +export interface FormGeneratorTreeProps { + provider: TreeNodeProvider; + ownership: Ownership; + title?: string; + compact?: boolean; + collapsible?: boolean; + defaultCollapsed?: boolean; + emptyMessage?: string; + showFilter?: boolean; + onNodeClick?: (node: TreeNode) => void; + onSelectionChange?: (selectedIds: Set) => void; + onRefresh?: () => void; + onSendToChat?: (node: TreeNode) => void; + className?: string; +} diff --git a/src/components/FormGenerator/index.ts b/src/components/FormGenerator/index.ts index 24ccc3f..c1e59ab 100644 --- a/src/components/FormGenerator/index.ts +++ b/src/components/FormGenerator/index.ts @@ -4,6 +4,7 @@ export * from './FormGeneratorList'; export * from './FormGeneratorForm'; export * from './FormGeneratorControls'; export * from './FormGeneratorReport'; +export * from './FormGeneratorTree'; // Alias FormGeneratorTable as FormGenerator for backward compatibility export { FormGeneratorTable as FormGenerator, FormGeneratorTableComponent as FormGeneratorComponent } from './FormGeneratorTable'; diff --git a/src/components/UnifiedDataBar/ChatsTab.module.css b/src/components/UnifiedDataBar/ChatsTab.module.css index 44bd65b..5a17f68 100644 --- a/src/components/UnifiedDataBar/ChatsTab.module.css +++ b/src/components/UnifiedDataBar/ChatsTab.module.css @@ -311,3 +311,28 @@ color: #f3f4f6; } } + +/* Touch devices: always show action buttons */ +@media (pointer: coarse) { + .chatActions { + display: flex; + } +} + +/* Mobile portrait */ +@media (max-width: 480px) { + .chatItem { + padding: 8px 8px; + font-size: 0.9rem; + } + + .actionBtn { + padding: 4px 5px; + font-size: 0.85rem; + } + + .search { + font-size: 0.9rem; + padding: 8px 10px; + } +} diff --git a/src/components/UnifiedDataBar/FilesTab.module.css b/src/components/UnifiedDataBar/FilesTab.module.css index 7a48a75..d992368 100644 --- a/src/components/UnifiedDataBar/FilesTab.module.css +++ b/src/components/UnifiedDataBar/FilesTab.module.css @@ -93,3 +93,12 @@ border-top-color: var(--border-dark, #374151); } } + +/* Mobile portrait */ +@media (max-width: 480px) { + .legend { + gap: 8px; + font-size: 0.7rem; + padding: 6px 8px; + } +} diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 75ba482..388ce93 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -1,29 +1,18 @@ -import React, { useCallback, useRef, useMemo } from 'react'; -import { FaFileImport, FaPaperPlane } from 'react-icons/fa'; +import React, { useCallback, useRef, useMemo, useState } from 'react'; +import { FaFileImport } from 'react-icons/fa'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; -import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; import { useApiRequest } from '../../hooks/useApi'; import { importWorkflowFromFile, WORKFLOW_FILE_EXTENSION, } from '../../api/workflowApi'; import { useToast } from '../../contexts/ToastContext'; -import { FormGeneratorTable } from '../FormGenerator/FormGeneratorTable'; -import { ViewActionButton } from '../FormGenerator/ActionButtons/ViewActionButton'; -import actionBtnStyles from '../FormGenerator/ActionButtons/ActionButton.module.css'; +import { FormGeneratorTree } from '../FormGenerator/FormGeneratorTree'; +import { createFolderFileProvider } from '../FormGenerator/FormGeneratorTree/providers/FolderFileProvider'; +import type { TreeNode } from '../FormGenerator/FormGeneratorTree'; import styles from './FilesTab.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; -import type { TableGroupNode } from '../../api/connectionApi'; - -function _findGroupDisplayName(nodes: TableGroupNode[], groupId: string): string | null { - for (const n of nodes) { - if (n.id === groupId) return (n.name && n.name.trim()) || groupId; - const sub = _findGroupDisplayName(n.subGroups, groupId); - if (sub !== null) return sub; - } - return null; -} interface FilesTabProps { context: UdbContext; @@ -38,23 +27,24 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat const { t } = useLanguage(); const { request } = useApiRequest(); const { showSuccess, showError } = useToast(); - const [isDragOver, setIsDragOver] = React.useState(false); - const [uploading, setUploading] = React.useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); - const { - data: files, - pagination, - loading, - refetch, - groupTree, - } = useUserFiles(); + const provider = useMemo(() => createFolderFileProvider(), []); + const [ownTreeKey, setOwnTreeKey] = useState(0); + const [sharedTreeKey, setSharedTreeKey] = useState(0); - const { handleFileDelete, previewingFiles } = useFileOperations() as any; + const _handleNodeClick = useCallback((node: TreeNode) => { + if (node.type === 'file') { + onFileSelect?.(node.id, node.name); + } + }, [onFileSelect]); - const _tableRefetch = useCallback(async (params?: any) => { - await refetch(params); - }, [refetch]); + const _handleRefresh = useCallback(() => { + setOwnTreeKey(k => k + 1); + setSharedTreeKey(k => k + 1); + }, []); const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { if (!context.instanceId || uploading) return; @@ -68,13 +58,13 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat headers: { 'Content-Type': 'multipart/form-data' }, }); } - await _tableRefetch(); + _handleRefresh(); } catch (err) { console.error('File upload failed:', err); } finally { setUploading(false); } - }, [context.instanceId, uploading, _tableRefetch]); + }, [context.instanceId, uploading, _handleRefresh]); const _handleDragOver = useCallback((e: React.DragEvent) => { if (e.dataTransfer.types.includes('Files')) { @@ -106,76 +96,36 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat } }, [_uploadFiles]); + /* Workflow import is only available when embedded in the graph editor */ + const _handleWorkflowImport = useCallback(async (fileId: string, fileName: string) => { + if (context.surface !== 'graphEditor' || !context.instanceId) return; + if (!fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return; + try { + const result = await importWorkflowFromFile(request, context.instanceId, { fileId }); + const warnings = result?.warnings ?? []; + const wfId = result?.workflow?.id; + if (warnings.length > 0) { + showSuccess(t('Workflow importiert ({n} Warnungen).', { n: String(warnings.length) })); + } else { + showSuccess(t('Workflow importiert (deaktiviert).')); + } + if (wfId && onWorkflowImported) onWorkflowImported(wfId); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + showError(t('Import fehlgeschlagen: {msg}', { msg })); + } + }, [context.surface, context.instanceId, request, showSuccess, showError, t, onWorkflowImported]); - const columns = useMemo(() => [{ - key: 'fileName', - label: t('Dateiname'), - sortable: false, - filterable: false, - searchable: false, - formatter: (value: any, row: any) => ( -
- {}} - idField="id" - nameField="fileName" - typeField="mimeType" - loadingStateName="previewingFiles" - hookData={{ previewingFiles }} - className={actionBtnStyles.compact} - /> - - {value} - -
- ), - }], [t, previewingFiles]); + const _handleNodeClickWithImport = useCallback((node: TreeNode) => { + _handleNodeClick(node); + if (node.type === 'file') { + _handleWorkflowImport(node.id, node.name); + } + }, [_handleNodeClick, _handleWorkflowImport]); - const _groupBulkActionsProvider = useMemo(() => { - if (!onSendToChat) return undefined; - return (groupId: string, itemIds: string[]) => [ - { - icon: , - title: t('Gruppe an Chat anhängen'), - onClick: () => { - const name = _findGroupDisplayName(groupTree, groupId) ?? groupId; - onSendToChat([{ id: groupId, type: 'group', name }]); - }, - disabled: itemIds.length === 0, - }, - ]; - }, [onSendToChat, groupTree, t]); - - const _customActions = useMemo(() => { - if (context.surface !== 'graphEditor') return []; - return [ - { - id: 'workflow.openInEditor', - icon: , - title: t('In Graph-Editor laden'), - onClick: async (row: any) => { - if (!context.instanceId || !row?.id) return; - if (!row.fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return; - try { - const result = await importWorkflowFromFile(request, context.instanceId, { fileId: row.id }); - const warnings = result?.warnings ?? []; - const wfId = result?.workflow?.id; - if (warnings.length > 0) { - showSuccess(t('Workflow importiert ({n} Warnungen).', { n: String(warnings.length) })); - } else { - showSuccess(t('Workflow importiert (deaktiviert).')); - } - if (wfId && onWorkflowImported) onWorkflowImported(wfId); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - showError(t('Import fehlgeschlagen: {msg}', { msg })); - } - }, - hidden: (row: any) => !row?.fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION), - }, - ]; - }, [context.surface, context.instanceId, t, request, showSuccess, showError, onWorkflowImported]); + const _handleSendToChat = useCallback((node: TreeNode) => { + onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]); + }, [onSendToChat]); return (
= ({ context, onFileSelect, onSendToChat onDrop={_handleDrop} > {isDragOver && ( -
+
{ e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; }} + onDragLeave={(e) => { + if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) { + setIsDragOver(false); + } + }} + onDrop={_handleDrop} + > {t('Dateien hier ablegen')}
)} @@ -208,8 +168,9 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat {uploading ? '...' : '+'} @@ -225,36 +186,32 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat />
- onFileSelect?.(row.id, row.fileName)} - actionButtons={[]} - customActions={_customActions} - hookData={{ - refetch: _tableRefetch, - pagination, - handleDelete: handleFileDelete, - previewingFiles, - groupTree, - }} - groupingConfig={{ contextKey: 'files/list', enabled: true }} - groupBulkActionsProvider={_groupBulkActionsProvider} - emptyMessage={t('Keine Dateien. Drag & Drop zum Hochladen.')} + +
- {'\uD83D\uDC64'} {t('Persönlich')} + {'\uD83D\uDC64'} {t('Persoenlich')} {'\uD83D\uDC65'} {t('Instanz')} {'\uD83C\uDFE2'} {t('Mandant')} {'\uD83D\uDD12'} {t('Neutralisiert')} diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.module.css b/src/components/UnifiedDataBar/UnifiedDataBar.module.css index 784d687..c7cdbb9 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.module.css +++ b/src/components/UnifiedDataBar/UnifiedDataBar.module.css @@ -58,3 +58,19 @@ border-bottom-color: var(--accent, #818cf8); } } + +/* Mobile portrait */ +@media (max-width: 480px) { + .tabBar { + padding: 4px 4px 0; + } + + .tab { + padding: 8px 6px; + font-size: 0.8rem; + } + + .tabContent { + padding: 4px; + } +} diff --git a/src/contexts/FileContext.tsx b/src/contexts/FileContext.tsx index 0f23385..ba9a3c2 100644 --- a/src/contexts/FileContext.tsx +++ b/src/contexts/FileContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext } from 'react'; -import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles'; +import { useFileOperations, useFolderOperations, type FilePreviewResult } from '../hooks/useFiles'; +import type { FolderInfo } from '../api/fileApi'; interface FileContextType { handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>; @@ -10,6 +11,13 @@ interface FileContextType { deletingFiles: Set; previewingFiles: Set; downloadingFiles: Set; + handleCreateFolder: (name: string, parentId?: string | null) => Promise; + handleRenameFolder: (folderId: string, name: string) => Promise; + handleDeleteFolderCascade: (folderId: string) => Promise<{ deletedFolders: number; deletedFiles: number }>; + handleMoveFolder: (folderId: string, parentId: string | null) => Promise; + handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise; + fetchOwnFolderTree: () => Promise; + fetchSharedFolderTree: () => Promise; } export const FileContext = createContext(undefined); @@ -26,6 +34,16 @@ export function FileProvider({ children }: { children: React.ReactNode }) { downloadingFiles, } = useFileOperations(); + const { + handleCreateFolder, + handleRenameFolder, + handleMoveFolder, + handleDeleteFolderCascade, + handleMoveFiles, + fetchOwnFolderTree, + fetchSharedFolderTree, + } = useFolderOperations(); + return ( {children} diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 5141f44..72d390d 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -12,6 +12,15 @@ import { updateFile as updateFileApi, deleteFile as deleteFileApi, deleteFiles as deleteFilesApi, + getFolderTree, + createFolder as createFolderApi, + renameFolder as renameFolderApi, + moveFolder as moveFolderApi, + deleteFolderCascade as deleteFolderCascadeApi, + patchFolderScope as patchFolderScopeApi, + patchFolderNeutralize as patchFolderNeutralizeApi, + moveFiles as moveFilesApi, + type FolderInfo, } from '../api/fileApi'; import type { TableGroupNode } from '../api/connectionApi'; @@ -697,4 +706,172 @@ export function useFileOperations() { handleInlineUpdate, isLoading }; +} + +// Folder operations hook +export function useFolderOperations() { + const [folderLoading, setFolderLoading] = useState(false); + const [folderError, setFolderError] = useState(null); + const { request } = useApiRequest(); + + const fetchOwnFolderTree = useCallback(async (): Promise => { + setFolderLoading(true); + setFolderError(null); + try { + return await getFolderTree(request, 'me'); + } catch (err: any) { + const msg = err?.message ?? 'Failed to fetch own folder tree'; + setFolderError(msg); + throw err; + } finally { + setFolderLoading(false); + } + }, [request]); + + const fetchSharedFolderTree = useCallback(async (): Promise => { + setFolderLoading(true); + setFolderError(null); + try { + return await getFolderTree(request, 'shared'); + } catch (err: any) { + const msg = err?.message ?? 'Failed to fetch shared folder tree'; + setFolderError(msg); + throw err; + } finally { + setFolderLoading(false); + } + }, [request]); + + const handleCreateFolder = useCallback(async ( + name: string, + parentId?: string | null, + ): Promise => { + setFolderLoading(true); + setFolderError(null); + try { + return await createFolderApi(request, name, parentId); + } catch (err: any) { + const msg = err?.message ?? 'Failed to create folder'; + setFolderError(msg); + throw err; + } finally { + setFolderLoading(false); + } + }, [request]); + + const handleRenameFolder = useCallback(async ( + folderId: string, + name: string, + ): Promise => { + setFolderLoading(true); + setFolderError(null); + try { + return await renameFolderApi(request, folderId, name); + } catch (err: any) { + const msg = err?.message ?? 'Failed to rename folder'; + setFolderError(msg); + throw err; + } finally { + setFolderLoading(false); + } + }, [request]); + + const handleMoveFolder = useCallback(async ( + folderId: string, + parentId: string | null, + ): Promise => { + setFolderLoading(true); + setFolderError(null); + try { + return await moveFolderApi(request, folderId, parentId); + } catch (err: any) { + const msg = err?.message ?? 'Failed to move folder'; + setFolderError(msg); + throw err; + } finally { + setFolderLoading(false); + } + }, [request]); + + const handleDeleteFolderCascade = useCallback(async ( + folderId: string, + ): Promise<{ deletedFolders: number; deletedFiles: number }> => { + setFolderLoading(true); + setFolderError(null); + try { + return await deleteFolderCascadeApi(request, folderId); + } catch (err: any) { + const msg = err?.message ?? 'Failed to delete folder'; + setFolderError(msg); + throw err; + } finally { + setFolderLoading(false); + } + }, [request]); + + const handlePatchFolderScope = useCallback(async ( + folderId: string, + scope: string, + cascadeToFiles: boolean = false, + ): Promise<{ folderId: string; scope: string; filesUpdated: number }> => { + setFolderLoading(true); + setFolderError(null); + try { + return await patchFolderScopeApi(request, folderId, scope, cascadeToFiles); + } catch (err: any) { + const msg = err?.message ?? 'Failed to patch folder scope'; + setFolderError(msg); + throw err; + } finally { + setFolderLoading(false); + } + }, [request]); + + const handlePatchFolderNeutralize = useCallback(async ( + folderId: string, + neutralize: boolean, + ): Promise<{ folderId: string; neutralize: boolean; filesUpdated: number }> => { + setFolderLoading(true); + setFolderError(null); + try { + return await patchFolderNeutralizeApi(request, folderId, neutralize); + } catch (err: any) { + const msg = err?.message ?? 'Failed to patch folder neutralize'; + setFolderError(msg); + throw err; + } finally { + setFolderLoading(false); + } + }, [request]); + + const handleMoveFiles = useCallback(async ( + fileIds: string[], + targetFolderId: string | null, + ): Promise => { + setFolderLoading(true); + setFolderError(null); + try { + await moveFilesApi(request, fileIds, targetFolderId); + } catch (err: any) { + const msg = err?.message ?? 'Failed to move files'; + setFolderError(msg); + throw err; + } finally { + setFolderLoading(false); + } + }, [request]); + + return { + folderLoading, + folderError, + fetchOwnFolderTree, + fetchSharedFolderTree, + handleCreateFolder, + handleRenameFolder, + handleMoveFolder, + handleDeleteFolderCascade, + handlePatchFolderScope, + handlePatchFolderNeutralize, + handleMoveFiles, + }; } \ No newline at end of file diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index 4644873..69b464b 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -1177,11 +1177,65 @@ const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }> ); }; +const _ProducedFilesSection: React.FC<{ + steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>; + unassignedFiles?: Array<{ id: string; fileName?: string }>; +}> = ({ steps, unassignedFiles }) => { + const { t } = useLanguage(); + const seen = new Set(); + const allFiles: Array<{ id: string; fileName?: string }> = []; + for (const step of steps) { + for (const f of step.outputFiles ?? []) { + if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } + } + } + for (const f of unassignedFiles ?? []) { + if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } + } + if (!allFiles.length) return null; + const baseUrl = api.defaults.baseURL || ''; + return ( +
+
+ + {t('Ergebnisse')} ({allFiles.length}) +
+
+ {allFiles.map(f => ( + + + {f.fileName || f.id} + + ))} +
+
+ ); +}; + +function _downloadJson(data: unknown, fileName: string) { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +} + interface _WorkspaceTabProps { runId: string | null; onBack: () => void; } +const _TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'error', 'stopped']); +const _POLL_INTERVAL_MS = 3000; + const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => { const { t } = useLanguage(); const { request } = useApiRequest(); @@ -1205,6 +1259,18 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => { else setRunDetail(null); }, [runId, _loadDetail]); + useEffect(() => { + if (!runId || !runDetail) return; + const status = runDetail.run?.status; + if (status && _TERMINAL_STATUSES.has(status)) return; + const timer = setInterval(() => { + fetchWorkspaceRunDetail(request, runId) + .then(detail => setRunDetail(detail)) + .catch(() => {}); + }, _POLL_INTERVAL_MS); + return () => clearInterval(timer); + }, [runId, runDetail, request]); + if (!runId) { return (
@@ -1237,6 +1303,7 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => { {run.error}
)} + <_ProducedFilesSection steps={steps} unassignedFiles={unassignedFiles} />

{t('Schritte')}

{steps.length === 0 ? (

{t('Keine Schritte protokolliert.')}

@@ -1262,8 +1329,13 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
{hasInput && (
-
+
{t('Input')} + {inputData !== undefined && inputData !== null && ( + + )}
<_DataBlock data={inputData} /> <_FileLinkList files={inputFiles} /> @@ -1271,8 +1343,13 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => { )} {hasOutput && (
-
+
{t('Output')} + {outputData !== undefined && outputData !== null && ( + + )}
<_DataBlock data={outputData} /> <_FileLinkList files={outputFiles} /> @@ -1349,12 +1426,12 @@ export const AutomationsDashboardPage: React.FC = () => { }, { id: 'dashboard', - label: t('Dashboard'), + label: t('Workflow-Durchläufe'), content: <_DashboardTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} />, }, { id: 'workspace', - label: t('Workspace'), + label: t('Durchlauf-Details'), content: <_WorkspaceTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />, }, ], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace]); diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 36c7802..d1d019e 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -1,23 +1,25 @@ /** * FilesPage * - * Full-width file management using FormGeneratorTable with persistent grouping. - * Organisation exclusively via groupTree/groupId — no physical folder navigation. + * Split-view file management: tree panel on the left (FormGeneratorTree), + * FormGeneratorTable on the right. Two modes: + * - "Ordner-Sicht": table filtered by selected folder in the tree + * - "Alle Dateien": table shows all files without folder filter */ -import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useMemo, useEffect, useRef, useCallback, type PointerEvent as RPointerEvent } from 'react'; import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; -import { FaSync, FaUpload, FaDownload, FaLock, FaLockOpen, FaFileArchive, FaTrash } from 'react-icons/fa'; +import { FormGeneratorTree } from '../../components/FormGenerator/FormGeneratorTree'; +import { createFolderFileProvider } from '../../components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider'; +import type { TreeNode } from '../../components/FormGenerator/FormGeneratorTree'; +import { FaSync, FaUpload, FaDownload, FaTree, FaTable } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; -import { useApiRequest } from '../../hooks/useApi'; -import { patchGroupScope, downloadGroupZip, deleteGroup } from '../../api/fileApi'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; import { getUserDataCache } from '../../utils/userCache'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; -import type { GroupBulkAction } from '../../components/FormGenerator/GroupingManager/GroupRow'; interface UserFile { id: string; @@ -28,11 +30,16 @@ interface UserFile { [key: string]: any; } +type ViewMode = 'folder' | 'all'; + export const FilesPage: React.FC = () => { const { t } = useLanguage(); const fileInputRef = useRef(null); const { showSuccess, showError } = useToast(); - const { request } = useApiRequest(); + + const [viewMode, setViewMode] = useState('folder'); + const provider = useMemo(() => createFolderFileProvider(), []); + const [treeKey, setTreeKey] = useState(0); // ── Table data ──────────────────────────────────────────────────────── const { @@ -43,7 +50,6 @@ export const FilesPage: React.FC = () => { error, refetch: tableRefetch, pagination, - groupTree, fetchFileById, updateFileOptimistically, } = useUserFiles(); @@ -63,20 +69,68 @@ export const FilesPage: React.FC = () => { const [editingFile, setEditingFile] = useState(null); const [selectedFiles, setSelectedFiles] = useState([]); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const [highlightedFileId, setHighlightedFileId] = useState(null); - // ── Table refetch wrapper ────────────────────────────────────────────── + const [treeWidth, setTreeWidth] = useState(300); + const [treeVisible, setTreeVisible] = useState(true); + const [tableVisible, setTableVisible] = useState(true); + const draggingRef = useRef(false); + const splitContainerRef = useRef(null); + + const _handleDividerPointerDown = useCallback((e: RPointerEvent) => { + e.preventDefault(); + draggingRef.current = true; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, []); + + const _handleDividerPointerMove = useCallback((e: RPointerEvent) => { + if (!draggingRef.current || !splitContainerRef.current) return; + const rect = splitContainerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + setTreeWidth(Math.max(180, Math.min(x, rect.width - 200))); + }, []); + + const _handleDividerPointerUp = useCallback(() => { + draggingRef.current = false; + }, []); + + // ── Table refetch wrapper (filters by selectedFolderId in folder mode) ── const _tableRefetch = useCallback(async (params?: any) => { - await tableRefetch(params); - }, [tableRefetch]); + const nextParams = { ...(params || {}) }; + const nextFilters = { ...(nextParams.filters || {}) }; + if (viewMode === 'folder' && selectedFolderId) { + nextFilters.folderId = selectedFolderId; + } else { + delete nextFilters.folderId; + } + nextParams.filters = nextFilters; + await tableRefetch(nextParams); + }, [tableRefetch, selectedFolderId, viewMode]); const _refreshAll = useCallback(async () => { await _tableRefetch({ page: 1, pageSize: 25 }); + setTreeKey(k => k + 1); }, [_tableRefetch]); - // Initial fetch useEffect(() => { _tableRefetch({ page: 1, pageSize: 25 }); - }, [_tableRefetch]); + }, [selectedFolderId, viewMode, _tableRefetch]); + + // ── Tree interaction ────────────────────────────────────────────────── + const _handleTreeNodeClick = useCallback((node: TreeNode) => { + if (node.type === 'folder') { + setSelectedFolderId(node.id); + } else if (node.type === 'file') { + setSelectedFolderId(node.parentId); + setHighlightedFileId(node.id); + requestAnimationFrame(() => { + const row = document.querySelector('tr[data-highlighted="true"]'); + if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + setTimeout(() => setHighlightedFileId(null), 2500); + } + }, []); // ── Columns ─────────────────────────────────────────────────────────── const columns = useMemo(() => { @@ -181,6 +235,7 @@ export const FilesPage: React.FC = () => { } if (fileInputRef.current) fileInputRef.current.value = ''; await _tableRefetch(); + setTreeKey(k => k + 1); if (successCount > 0) { showSuccess( t('Upload erfolgreich'), @@ -194,55 +249,6 @@ export const FilesPage: React.FC = () => { } }; - const _groupBulkActionsProvider = useCallback((groupId: string, itemIds: string[]): GroupBulkAction[] => { - return [ - { - icon: , - title: t('Scope: personal'), - onClick: async () => { - try { - await patchGroupScope(request, groupId, 'personal'); - showSuccess(t('Scope aktualisiert'), t('{n} Dateien auf personal gesetzt', { n: String(itemIds.length) })); - await _tableRefetch(); - } catch (e) { showError(t('Fehler'), String(e)); } - }, - }, - { - icon: , - title: t('Scope: mandate'), - onClick: async () => { - try { - await patchGroupScope(request, groupId, 'mandate'); - showSuccess(t('Scope aktualisiert'), t('{n} Dateien auf mandate gesetzt', { n: String(itemIds.length) })); - await _tableRefetch(); - } catch (e) { showError(t('Fehler'), String(e)); } - }, - }, - { - icon: , - title: t('ZIP herunterladen'), - onClick: async () => { - try { await downloadGroupZip(groupId); } - catch (e) { showError(t('Fehler'), String(e)); } - }, - disabled: itemIds.length === 0, - }, - { - icon: , - title: t('Gruppe + Dateien löschen'), - variant: 'danger' as const, - onClick: async () => { - try { - await deleteGroup(request, groupId, true); - showSuccess(t('Gelöscht'), t('Gruppe und {n} Dateien gelöscht', { n: String(itemIds.length) })); - await _tableRefetch(); - } catch (e) { showError(t('Fehler'), String(e)); } - }, - disabled: itemIds.length === 0, - }, - ]; - }, [request, showSuccess, showError, _tableRefetch, t]); - const _onRowDragStart = useCallback((e: React.DragEvent, row: UserFile) => { const isInSelection = selectedFiles.some(f => f.id === row.id); if (isInSelection && selectedFiles.length > 1) { @@ -262,7 +268,7 @@ export const FilesPage: React.FC = () => { return (
- ⚠️ + ⚠️

{t('Fehler beim Laden der Dateien: {detail}', { detail: String(error) })}

+
+ + +
-
-
- {canCreate && ( - - )} -
+
+ {/* Left panel: Tree */} + {treeVisible && ( +
+ _tableRefetch()} + /> + +
+ )} -
- setSelectedFiles(rows as UserFile[])} - rowDraggable={true} - onRowDragStart={_onRowDragStart} - actionButtons={[ - { - type: 'view' as const, - onAction: () => {}, - title: t('Vorschau'), - idField: 'id', - nameField: 'fileName', - typeField: 'mimeType', - loadingStateName: 'previewingFiles', - }, - ...(canUpdate ? [{ - type: 'edit' as const, - onAction: handleEditClick, - title: t('Bearbeiten'), - disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann bearbeiten') } : false, - }] : []), - ...(canDelete ? [{ - type: 'delete' as const, - title: t('Löschen'), - loading: (row: UserFile) => deletingFiles.has(row.id), - disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann löschen') } : false, - }] : []), - ]} - customActions={[ - { - id: 'download', - icon: , - onClick: handleDownload, - title: t('Herunterladen'), - loading: (row: UserFile) => downloadingFiles.has(row.id), - }, - ]} - onDelete={handleDelete} - onDeleteMultiple={handleDeleteMultiple} - hookData={{ - refetch: _tableRefetch, - pagination, - permissions, - handleDelete: handleFileDelete, - handleInlineUpdate, - updateOptimistically: updateFileOptimistically, - previewingFiles, - groupTree, + {/* Resizable divider */} + {treeVisible && tableVisible && ( +
+ > +
+
+ )} + + {/* Right panel: Table with view-mode toggle */} + {tableVisible && ( +
+
+ + + +
+ + {canCreate && ( + + )} +
+ +
+ setSelectedFiles(rows as UserFile[])} + rowDraggable={true} + onRowDragStart={_onRowDragStart} + getRowDataAttributes={(row: UserFile) => ({ + highlighted: row.id === highlightedFileId ? 'true' : 'false', + })} + actionButtons={[ + { + type: 'view' as const, + onAction: () => {}, + title: t('Vorschau'), + idField: 'id', + nameField: 'fileName', + typeField: 'mimeType', + loadingStateName: 'previewingFiles', + }, + ...(canUpdate ? [{ + type: 'edit' as const, + onAction: handleEditClick, + title: t('Bearbeiten'), + disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentuemer kann bearbeiten') } : false, + }] : []), + ...(canDelete ? [{ + type: 'delete' as const, + title: t('Loeschen'), + loading: (row: UserFile) => deletingFiles.has(row.id), + disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentuemer kann loeschen') } : false, + }] : []), + ]} + customActions={[ + { + id: 'download', + icon: , + onClick: handleDownload, + title: t('Herunterladen'), + loading: (row: UserFile) => downloadingFiles.has(row.id), + }, + ]} + onDelete={handleDelete} + onDeleteMultiple={handleDeleteMultiple} + hookData={{ + refetch: _tableRefetch, + pagination, + permissions, + handleDelete: handleFileDelete, + handleInlineUpdate, + updateOptimistically: updateFileOptimistically, + previewingFiles, + }} + emptyMessage={t('Keine Dateien gefunden')} + /> +
+ )}
{editingFile && ( @@ -378,7 +477,7 @@ export const FilesPage: React.FC = () => {

{t('Datei bearbeiten')}

- +
{formAttributes.length === 0 ? (