Compare commits

..

3 commits

Author SHA1 Message Date
ValueOn AG
d42fa02736 fix import 2026-05-04 09:33:14 +02:00
ValueOn AG
dca587a2df fixed ux for expand object scrolling 2026-05-04 09:33:14 +02:00
ValueOn AG
79557e51ed fixed component formgeneratortree and truastee workflows 2026-05-04 09:33:14 +02:00
30 changed files with 3595 additions and 2814 deletions

View file

@ -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: [],
},
],
},

View file

@ -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<FolderInfo[]> {
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<FolderInfo> {
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<FolderInfo> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'patch',
data: { name },
});
}
export async function moveFolder(
request: ApiRequestFunction,
folderId: string,
parentId: string | null,
): Promise<FolderInfo> {
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<void> {
await Promise.all(
fileIds.map((fileId) =>
request({
url: `/api/files/${fileId}`,
method: 'put',
data: { folderId: targetFolderId },
}),
),
);
}

View file

@ -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;
}

File diff suppressed because it is too large Load diff

View file

@ -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<string, unknown>;
}
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<BrowseEntry[]>;
/** 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 (
<div
className={`${styles.treeNode} ${styles.fileNode} ${isSelected ? styles.selected : ''}`}
onClick={() => onSelect(entry.path)}
title={entry.path}
>
<span className={styles.chevron + ' ' + styles.empty} />
<span className={styles.fileIcon}>{_fileIcon(entry.mimeType)}</span>
<span className={styles.folderName}>{entry.name}</span>
{entry.size != null && (
<span className={styles.fileSize}>
{(entry.size / 1024).toFixed(0)}K
</span>
)}
</div>
);
}
/* ── 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<string>;
loadedChildren: Record<string, BrowseEntry[]>;
loadingPaths: Set<string>;
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 (
<div>
<div
className={`${styles.treeNode} ${onSelectFolder && isSelected ? styles.selected : ''}`}
onClick={handleRowClick}
title={entry.path}
>
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''}`}
onClick={handleChevronClick}
title={isExpanded ? t('Einklappen') : t('Erweitern')}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
<span className={styles.folderName}>{entry.name}</span>
{isLoading && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}></span>
)}
</div>
{isExpanded && (
<div className={styles.children}>
{isLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
{t('Wird geladen…')}
</div>
) : (
<>
{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 && (
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
{t('Leer')}
</div>
)}
</>
)}
</div>
)}
</div>
);
}
/* ── Root component ─────────────────────────────────────────────────────── */
export function SharepointBrowseTree({
rootPath = '/',
onLoadChildren,
onSelectFile,
onSelectFolder,
foldersOnly = false,
selectedPath,
initialChildren = [],
}: SharepointBrowseTreeProps) {
const { t } = useLanguage();
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set([rootPath]));
const [loadedChildren, setLoadedChildren] = useState<Record<string, BrowseEntry[]>>(() =>
initialChildren.length > 0 ? { [rootPath]: initialChildren } : {}
);
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(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 (
<div className={styles.folderTree}>
<div
className={`${styles.treeNode} ${selectedPath === null || selectedPath === undefined ? styles.selected : ''}`}
style={{ fontWeight: 600 }}
>
<span
className={`${styles.chevron} ${isRootExpanded ? styles.expanded : ''}`}
onClick={() => handleToggle(rootPath)}
title={isRootExpanded ? t('Einklappen') : t('Erweitern')}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}><FaGlobe /></span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>{t('SharePoint')}</span>
{rootLoading && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}></span>
)}
</div>
{isRootExpanded && (
<div className={styles.children}>
{rootLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
{t('Sites werden geladen…')}
</div>
) : (
<>
{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 && (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
{t('Keine Einträge')}
</div>
)}
</>
)}
</div>
)}
</div>
);
}

View file

@ -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;
}

View file

@ -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<boolean>;
}
export const FileActionBottomSheet: React.FC<Props> = ({
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 (
<>
<div className={styles.backdrop} onClick={onClose} />
<div className={styles.sheet} role="dialog" aria-modal="true" aria-label={title}>
<div className={styles.handle} aria-hidden="true" />
{title && <div className={styles.title}>{title}</div>}
{actions.length === 0 ? (
<div className={styles.empty}></div>
) : (
actions.map((a) => {
const Icon = a.icon;
const cls = a.danger ? `${styles.item} ${styles.danger}` : styles.item;
return (
<button
key={a.id}
type="button"
className={cls}
onClick={() => _handleClick(a)}
style={a.iconColor ? { color: a.iconColor } : undefined}
>
<span className={styles.icon}>
<Icon size={17} />
</span>
<span className={styles.label}>{resolveActionLabel(a, target)}</span>
</button>
);
})
)}
</div>
</>
);
};

View file

@ -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;
}

View file

@ -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<boolean>;
}
export const FileActionContextMenu: React.FC<Props> = ({
anchor,
actions,
target,
ctx,
onClose,
title,
confirm,
}) => {
const menuRef = useRef<HTMLDivElement>(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 (
<>
<div
className={styles.backdrop}
onClick={onClose}
onContextMenu={(e) => {
e.preventDefault();
onClose();
}}
/>
<div
ref={menuRef}
className={styles.menu}
role="menu"
tabIndex={-1}
style={{ left: adjusted.x, top: adjusted.y }}
>
{title && <div className={styles.header}>{title}</div>}
{actions.length === 0 ? (
<div className={styles.empty}></div>
) : (
actions.map((a, idx) => {
const Icon = a.icon;
const isDangerCls = a.danger ? `${styles.item} ${styles.danger}` : styles.item;
return (
<React.Fragment key={a.id}>
{idx > 0 && a.danger && actions[idx - 1] && !actions[idx - 1].danger && (
<div className={styles.divider} />
)}
<button
type="button"
role="menuitem"
className={isDangerCls}
onClick={() => _handleClick(a)}
style={a.iconColor ? { color: a.iconColor } : undefined}
>
<span className={styles.icon}>
<Icon size={13} />
</span>
<span className={styles.label}>{resolveActionLabel(a, target)}</span>
{a.shortcut && <span className={styles.shortcut}>{_formatShortcut(a.shortcut)}</span>}
</button>
</React.Fragment>
);
})
)}
</div>
</>
);
};
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 ? '' : '+');
}

View file

@ -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<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onSendToChat?: (
items: Array<{ id: string; type: 'file' | 'folder'; name: string }>,
) => void;
/** Translator (i18n) — typischerweise `t` aus dem LanguageContext. */
t?: (key: string, vars?: Record<string, string>) => 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<BuiltinCallbacks['t']> = (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<BuiltinCallbacks['t']> = 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<boolean>,
): Promise<void> {
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 };

View file

@ -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> | 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: [] };
}

View file

@ -1,75 +0,0 @@
import { useCallback, useRef } from 'react';
/**
* Long-Press-Erkennung über Pointer-Events.
*
* Liefert Handler die direkt auf `<div>` 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<number | null>(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,
};
}

View file

@ -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';
}

View file

@ -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);
}

View file

@ -0,0 +1,948 @@
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<string, string> = {
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<T = any> {
node: TreeNode<T>;
depth: number;
hasChildren: boolean;
}
function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeNode<T>[]> {
const map = new Map<string | '__root__', TreeNode<T>[]>();
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<T>(
nodes: TreeNode<T>[],
expandedIds: Set<string>,
): FlatEntry<T>[] {
const childMap = _buildChildMap(nodes);
const result: FlatEntry<T>[] = [];
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<T>(nodeId: string, nodes: TreeNode<T>[]): 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<T = any> {
entry: FlatEntry<T>;
isSelected: boolean;
isFocused: boolean;
isExpanded: boolean;
isRenaming: boolean;
isDragOver: boolean;
isDragging: boolean;
ownership: Ownership;
compact: boolean;
provider: TreeNodeProvider<T>;
onToggleExpand: (id: string) => void;
onToggleSelect: (id: string, e: React.MouseEvent) => void;
onNodeClick: (node: TreeNode<T>) => void;
onStartRename: (id: string) => void;
onConfirmRename: (id: string, newName: string) => void;
onCancelRename: () => void;
onDelete: (id: string) => void;
onDownload: (node: TreeNode<T>) => void;
onSendToChat?: (node: TreeNode<T>) => void;
onCycleScope: (node: TreeNode<T>) => void;
onToggleNeutralize: (node: TreeNode<T>) => void;
onDragStart: (e: React.DragEvent, node: TreeNode<T>) => void;
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent, node: TreeNode<T>) => void;
}
const TreeNodeRow = React.memo(function TreeNodeRow<T>({
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<T>) {
const { node, depth, hasChildren } = entry;
const renameRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div
className={rowClasses}
onClick={_handleRowClick}
onDoubleClick={_handleDoubleClick}
draggable
onDragStart={(e) => 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}
>
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
<input
type="checkbox"
className={styles.nodeCheckbox}
checked={isSelected}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
onToggleSelect(node.id, e as unknown as React.MouseEvent);
}}
tabIndex={-1}
/>
{hasChildren ? (
<span
className={`${styles.expandChevron} ${isExpanded ? styles.expandChevronExpanded : ''}`}
onClick={_handleExpandClick}
role="button"
tabIndex={-1}
>
<FaChevronRight />
</span>
) : (
<span className={styles.expandChevronPlaceholder} />
)}
{node.icon && <span className={styles.nodeIcon}>{node.icon}</span>}
{isRenaming ? (
<input
ref={renameRef}
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={_handleRenameKeyDown}
onBlur={_handleRenameBlur}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.nodeName}>{node.name}</span>
)}
{node.contextOrphan && (
<span className={styles.orphanBadge} title="Context orphan">
<FaUnlink />
</span>
)}
<span className={styles.nodeSize}>
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
</span>
<div className={styles.nodeActionsHover}>
{canRename && (
<button
className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onStartRename(node.id); }}
title="Umbenennen"
tabIndex={-1}
>
{'\u270F\uFE0F'}
</button>
)}
{node.type !== 'folder' && (
<button
className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
title="Datei herunterladen"
tabIndex={-1}
>
{'\u{1F4E5}'}
</button>
)}
{canDelete && (
<button
className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onDelete(node.id); }}
title="Loeschen"
tabIndex={-1}
>
{'\u{1F5D1}\uFE0F'}
</button>
)}
</div>
<div className={styles.nodeActionsPersistent}>
{onSendToChat && (
<button
className={styles.emojiBtn}
onClick={(e) => {
e.stopPropagation();
onSendToChat(node);
}}
title="In Chat senden"
tabIndex={-1}
>
{'\u{1F4AC}'}
</button>
)}
{node.scope !== undefined && (
<button
className={`${styles.emojiBtn} ${canPatchScope ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => {
e.stopPropagation();
if (canPatchScope) onCycleScope(node);
}}
title={`Scope: ${node.scope}`}
tabIndex={-1}
>
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
</button>
)}
{node.neutralize !== undefined && (
<button
className={`${styles.emojiBtn} ${canPatchNeutralize ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => {
e.stopPropagation();
if (canPatchNeutralize) onToggleNeutralize(node);
}}
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
tabIndex={-1}
style={{ opacity: node.neutralize ? 1 : 0.35 }}
>
{_NEUTRALIZE_EMOJI}
</button>
)}
</div>
</div>
);
}) as <T>(props: TreeNodeRowProps<T>) => React.ReactElement;
export function FormGeneratorTree<T = any>({
provider,
ownership,
title,
compact = false,
collapsible = false,
defaultCollapsed = false,
emptyMessage,
showFilter = false,
onNodeClick,
onSelectionChange,
onRefresh,
onSendToChat,
className,
}: FormGeneratorTreeProps<T>) {
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [renamingId, setRenamingId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [sectionCollapsed, setSectionCollapsed] = useState(defaultCollapsed);
const [focusedId, setFocusedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
const [filterText, setFilterText] = useState('');
const lastSelectedIdRef = useRef<string | null>(null);
const treeContentRef = useRef<HTMLDivElement>(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<string>();
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<string>) => {
setSelectedIds(newSelection);
onSelectionChange?.(newSelection);
},
[onSelectionChange],
);
const _handleToggleExpand = useCallback(
async (id: string) => {
const wasExpanded = expandedIds.has(id);
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 && !wasExpanded) {
const childMap = _buildChildMap(nodes);
const existingChildren = childMap.get(id);
if (!existingChildren || existingChildren.length === 0) {
const childNodes = await provider.loadChildren(id, ownership);
if (childNodes.length > 0) {
setNodes((prev) => [...prev, ...childNodes]);
}
}
setTimeout(() => {
_scrollExpandedNodeToCenter(id);
}, 50);
}
},
[nodes, expandedIds, provider, ownership],
);
const _scrollExpandedNodeToCenter = useCallback((nodeId: string) => {
const container = treeContentRef.current;
if (!container) return;
const el = container.querySelector(`[data-node-id="${nodeId}"]`) as HTMLElement | null;
if (!el) return;
const containerRect = container.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const midpoint = containerRect.top + containerRect.height / 2;
if (elRect.top > midpoint) {
const scrollTarget = container.scrollTop + (elRect.top - midpoint);
container.scrollTo({ top: scrollTarget, behavior: 'smooth' });
}
}, []);
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<T>) => {
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<T>) => {
await provider.downloadNode?.(node);
},
[provider],
);
const _handleCycleScope = useCallback(
async (node: TreeNode<T>) => {
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<T>) => {
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<T>) => {
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<T>) => {
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<T>) => {
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 (
<div className={wrapperClasses}>
{title && (
<div
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined}
>
{collapsible && (
<span
className={`${styles.collapseChevron} ${!sectionCollapsed ? styles.collapseChevronExpanded : ''}`}
>
<FaChevronRight />
</span>
)}
<span className={styles.sectionTitle}>{title}</span>
<span className={styles.sectionCount}>{totalNodeCount}</span>
<button
className={styles.refreshBtn}
onClick={(e) => {
e.stopPropagation();
_handleRefresh();
}}
title="Aktualisieren"
tabIndex={-1}
>
<FaSyncAlt />
</button>
</div>
)}
{(!collapsible || !sectionCollapsed) && (
<div className={styles.treeWrapper}>
{showFilter && (
<div className={styles.filterRow}>
<input
className={styles.filterInput}
type="text"
placeholder="Filter..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
{filterText && (
<button
className={styles.filterClear}
onClick={() => setFilterText('')}
tabIndex={-1}
>
&times;
</button>
)}
</div>
)}
{selectedIds.size > 0 && batchActions.length > 0 && (
<div className={styles.batchToolbar}>
<span className={styles.batchCount}>{selectedIds.size} selected</span>
{batchActions.map((action: TreeBatchAction) => {
const ids = _filteredIdsForAction(action);
if (ids.length === 0) return null;
return (
<button
key={action.key}
className={`${styles.batchButton} ${action.danger ? styles.batchButtonDanger : ''}`}
onClick={async () => {
if (action.danger) {
if (!window.confirm(`${ids.length} ${action.label} wirklich loeschen?`)) return;
}
await action.onClick(ids);
await _handleRefresh();
}}
>
{action.icon && <span className={styles.batchButtonIcon}>{action.icon}</span>}
{action.label}
<span className={styles.batchButtonCount}>{ids.length}</span>
</button>
);
})}
</div>
)}
<div
ref={treeContentRef}
className={styles.treeContent}
role="tree"
tabIndex={0}
onKeyDown={_handleKeyDown}
>
{loading ? (
<div className={styles.loadingState}>
<div className={styles.loadingSpinner} />
</div>
) : flatEntries.length === 0 ? (
<div className={styles.emptyState}>
<span className={styles.emptyMessage}>
{emptyMessage ?? 'No items'}
</span>
</div>
) : (
flatEntries.map((entry) => (
<TreeNodeRow<T>
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}
/>
))
)}
</div>
</div>
)}
</div>
);
}
export default FormGeneratorTree;

View file

@ -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(
<FormGeneratorTree provider={provider} ownership="own" title="Documents" />,
);
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(<FormGeneratorTree provider={provider} ownership="own" />);
const tree = screen.getByRole('tree');
expect(tree.querySelector('[class*="loadingSpinner"]')).toBeInTheDocument();
});
it('shows empty message when no nodes', async () => {
const provider = _createMockProvider([]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
emptyMessage="Nothing here"
/>,
);
await waitFor(() => {
expect(screen.getByText('Nothing here')).toBeInTheDocument();
});
});
it('shows default empty message when no custom message', async () => {
const provider = _createMockProvider([]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('No items')).toBeInTheDocument();
});
});
it('renders nodes with correct names', async () => {
const provider = _createMockProvider([_ownFolder, _sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="shared" />);
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(
<FormGeneratorTree
provider={provider}
ownership="own"
onSelectionChange={onSelectionChange}
/>,
);
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<string>;
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(
<FormGeneratorTree
provider={provider}
ownership="own"
onSelectionChange={onSelectionChange}
/>,
);
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<string>;
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(
<FormGeneratorTree
provider={provider}
ownership="own"
onSelectionChange={onSelectionChange}
/>,
);
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<string>;
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<string>;
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(
<FormGeneratorTree
provider={provider}
ownership="shared"
onSelectionChange={onSelectionChange}
/>,
);
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<string>;
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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="shared" />);
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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="shared" />);
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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="shared" />);
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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="shared" />);
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(
<FormGeneratorTree
provider={provider}
ownership="own"
title="Documents"
collapsible
/>,
);
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(
<FormGeneratorTree
provider={provider}
ownership="own"
title="Documents"
collapsible
defaultCollapsed
/>,
);
// 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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="shared" />);
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<typeof vi.fn>;
expect(lastCall).toHaveBeenCalled();
});
expect(screen.queryByText('Delete All')).not.toBeInTheDocument();
});
});
});

View file

@ -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<string, string> = {}): DataTransfer {
const store: Record<string, string> = { ...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(<FormGeneratorTree provider={provider} ownership="own" />);
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<typeof vi.fn>).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(<FormGeneratorTree provider={provider} ownership="own" />);
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(<FormGeneratorTree provider={provider} ownership="shared" />);
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();
});
});

View file

@ -0,0 +1,9 @@
export { FormGeneratorTree } from './FormGeneratorTree';
export type {
TreeNode,
TreeNodeProvider,
TreeBatchAction,
FormGeneratorTreeProps,
Ownership,
ScopeValue,
} from './types';

View file

@ -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: <FaFolder />,
};
}
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: <FaFile />,
};
}
export function createFolderFileProvider(): TreeNodeProvider {
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
const typeMap = new Map<string, 'folder' | 'file'>();
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<string, any> = {};
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: <><FaFolder style={{ fontSize: 10, marginRight: 1 }} /><FaTrash /></>,
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: <FaTrash />,
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;

View file

@ -0,0 +1,64 @@
export type Ownership = 'own' | 'shared';
export type ScopeValue = 'personal' | 'featureInstance' | 'mandate' | 'global';
export interface TreeNode<T = any> {
id: string;
name: string;
type: string;
parentId: string | null;
ownership: Ownership;
scope?: ScopeValue;
neutralize?: boolean;
contextOrphan?: boolean;
icon?: React.ReactNode;
children?: TreeNode<T>[];
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<void>;
}
export interface TreeNodeProvider<T = any> {
rootKey: string;
loadChildren(parentId: string | null, ownership: Ownership): Promise<TreeNode<T>[]>;
canCreate?(parentId: string | null): boolean;
canRename?(node: TreeNode<T>): boolean;
canDelete?(node: TreeNode<T>): boolean;
canMove?(source: TreeNode<T>, target: TreeNode<T> | null): boolean;
canPatchScope?(node: TreeNode<T>): boolean;
canPatchNeutralize?(node: TreeNode<T>): boolean;
createChild?(parentId: string | null, name: string): Promise<TreeNode<T>>;
renameNode?(id: string, newName: string): Promise<void>;
deleteNodes?(ids: string[]): Promise<void>;
moveNodes?(ids: string[], targetParentId: string | null): Promise<void>;
patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise<void>;
patchNeutralize?(ids: string[], neutralize: boolean): Promise<void>;
downloadNode?(node: TreeNode<T>): Promise<void>;
getBatchActions?(): TreeBatchAction[];
}
export interface FormGeneratorTreeProps<T = any> {
provider: TreeNodeProvider<T>;
ownership: Ownership;
title?: string;
compact?: boolean;
collapsible?: boolean;
defaultCollapsed?: boolean;
emptyMessage?: string;
showFilter?: boolean;
onNodeClick?: (node: TreeNode<T>) => void;
onSelectionChange?: (selectedIds: Set<string>) => void;
onRefresh?: () => void;
onSendToChat?: (node: TreeNode<T>) => void;
className?: string;
}

View file

@ -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';

View file

@ -10,7 +10,7 @@
* - NavLink integration with React Router
*/
import React, { useState, useEffect, ReactNode } from 'react';
import React, { useState, useEffect, useRef, useCallback, ReactNode } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import styles from './TreeNavigation.module.css';
@ -151,6 +151,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const [isExpanded, setIsExpanded] = useState(
node.defaultExpanded ?? shouldAutoExpand ?? false
);
const containerRef = useRef<HTMLDivElement>(null);
// Auto-expand when path becomes active
useEffect(() => {
@ -159,6 +160,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
}
}, [currentPath, autoExpandActive, node]);
const _scrollAfterExpand = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const viewportMid = window.innerHeight / 2;
if (rect.top > viewportMid) {
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}, []);
// Check if this node is active (exact match or ancestor of active path)
const isActive = node.path ? currentPath === node.path || currentPath.startsWith(node.path + '/') : false;
// Differentiate: leaf active (strong highlight) vs group active (subtle text only)
@ -179,12 +190,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
}
if (isExpandable && !node.path) {
// If only expandable (no path), toggle expand
setIsExpanded(!isExpanded);
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) setTimeout(_scrollAfterExpand, 50);
} else if (isExpandable && node.path) {
// If both expandable and has path, expand on click but allow navigation
if (!isExpanded) {
setIsExpanded(true);
setTimeout(_scrollAfterExpand, 50);
}
}
@ -197,7 +209,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const handleToggleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) setTimeout(_scrollAfterExpand, 50);
};
// Render the node content (actions are rendered outside to avoid button-in-button nesting)
@ -255,7 +269,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const canRenderChildren = maxDepth === 0 || level < maxDepth;
return (
<div className={styles.treeNodeContainer}>
<div className={styles.treeNodeContainer} ref={containerRef}>
{nodeElement}
{node.actions && (
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -1,29 +1,17 @@
import React, { useCallback, useRef, useMemo } from 'react';
import { FaFileImport, FaPaperPlane } from 'react-icons/fa';
import React, { useCallback, useRef, useMemo, useState } from 'react';
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 +26,24 @@ const FilesTab: React.FC<FilesTabProps> = ({ 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<HTMLInputElement>(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 +57,13 @@ const FilesTab: React.FC<FilesTabProps> = ({ 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 +95,36 @@ const FilesTab: React.FC<FilesTabProps> = ({ 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) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<ViewActionButton
row={row}
onView={() => {}}
idField="id"
nameField="fileName"
typeField="mimeType"
loadingStateName="previewingFiles"
hookData={{ previewingFiles }}
className={actionBtnStyles.compact}
/>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
{value}
</span>
</div>
),
}], [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: <FaPaperPlane />,
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: <FaFileImport />,
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 (
<div
@ -185,13 +134,23 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onDrop={_handleDrop}
>
{isDragOver && (
<div style={{
position: 'absolute', inset: 0,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #F25843', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#F25843',
}}>
<div
style={{
position: 'absolute', inset: 0,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #F25843', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#F25843',
pointerEvents: 'auto',
}}
onDragOver={(e) => { 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')}
</div>
)}
@ -208,8 +167,9 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
{uploading ? '...' : '+'}
</button>
<button
onClick={() => _tableRefetch()}
onClick={_handleRefresh}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
title={t('Aktualisieren')}
>
{'\u21BB'}
</button>
@ -225,36 +185,32 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
/>
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<FormGeneratorTable
data={files || []}
columns={columns}
apiEndpoint="/api/files/list"
loading={loading}
pagination={true}
pageSize={50}
searchable={false}
filterable={false}
sortable={false}
selectable={false}
onRowClick={(row: any) => 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.')}
<FormGeneratorTree
key={`own-${ownTreeKey}`}
provider={provider}
ownership="own"
title={t('Eigene')}
compact={true}
showFilter={true}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
<FormGeneratorTree
key={`shared-${sharedTreeKey}`}
provider={provider}
ownership="shared"
title={t('Geteilt mit mir')}
compact={true}
collapsible={true}
defaultCollapsed={true}
emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
</div>
<div className={styles.legend}>
<span>{'\uD83D\uDC64'} {t('Persönlich')}</span>
<span>{'\uD83D\uDC64'} {t('Persoenlich')}</span>
<span>{'\uD83D\uDC65'} {t('Instanz')}</span>
<span>{'\uD83C\uDFE2'} {t('Mandant')}</span>
<span>{'\uD83D\uDD12'} {t('Neutralisiert')}</span>

View file

@ -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;
}
}

View file

@ -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<string>;
previewingFiles: Set<string>;
downloadingFiles: Set<string>;
handleCreateFolder: (name: string, parentId?: string | null) => Promise<FolderInfo>;
handleRenameFolder: (folderId: string, name: string) => Promise<FolderInfo>;
handleDeleteFolderCascade: (folderId: string) => Promise<{ deletedFolders: number; deletedFiles: number }>;
handleMoveFolder: (folderId: string, parentId: string | null) => Promise<FolderInfo>;
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
fetchOwnFolderTree: () => Promise<FolderInfo[]>;
fetchSharedFolderTree: () => Promise<FolderInfo[]>;
}
export const FileContext = createContext<FileContextType | undefined>(undefined);
@ -26,6 +34,16 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
downloadingFiles,
} = useFileOperations();
const {
handleCreateFolder,
handleRenameFolder,
handleMoveFolder,
handleDeleteFolderCascade,
handleMoveFiles,
fetchOwnFolderTree,
fetchSharedFolderTree,
} = useFolderOperations();
return (
<FileContext.Provider
value={{
@ -39,6 +57,13 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
deletingFiles,
previewingFiles,
downloadingFiles,
handleCreateFolder,
handleRenameFolder,
handleMoveFolder,
handleDeleteFolderCascade,
handleMoveFiles,
fetchOwnFolderTree,
fetchSharedFolderTree,
}}
>
{children}

View file

@ -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<string | null>(null);
const { request } = useApiRequest();
const fetchOwnFolderTree = useCallback(async (): Promise<FolderInfo[]> => {
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<FolderInfo[]> => {
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<FolderInfo> => {
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<FolderInfo> => {
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<FolderInfo> => {
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<void> => {
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,
};
}

View file

@ -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<string>();
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 (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: 'var(--surface-secondary, rgba(0,123,255,0.04))', border: '1px solid var(--border-color)', borderRadius: 8 }}>
<div style={{ fontSize: '0.82rem', fontWeight: 600, marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: 6 }}>
<FaDownload style={{ opacity: 0.6 }} />
{t('Ergebnisse')} ({allFiles.length})
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{allFiles.map(f => (
<a
key={f.id}
href={`${baseUrl}/api/files/${f.id}/download`}
download
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '0.35rem 0.7rem', background: 'var(--surface-primary, #fff)', border: '1px solid var(--border-color)', borderRadius: 6, textDecoration: 'none', color: 'var(--primary-color)', fontSize: '0.82rem', fontWeight: 500 }}
>
<FaDownload style={{ fontSize: '0.7rem' }} />
{f.fileName || f.id}
</a>
))}
</div>
</div>
);
};
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 (
<div style={{ padding: '1rem', flex: 1, color: 'var(--text-secondary)' }}>
@ -1237,6 +1303,7 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
{run.error}
</div>
)}
<_ProducedFilesSection steps={steps} unassignedFiles={unassignedFiles} />
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Schritte')}</h4>
{steps.length === 0 ? (
<p style={{ color: 'var(--text-secondary)' }}>{t('Keine Schritte protokolliert.')}</p>
@ -1262,8 +1329,13 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginTop: '0.5rem' }}>
{hasInput && (
<section>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem' }}>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem', display: 'flex', alignItems: 'center', gap: 6 }}>
{t('Input')}
{inputData !== undefined && inputData !== null && (
<button type="button" onClick={() => _downloadJson(inputData, `${step.nodeId}-input.json`)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--primary-color)', fontSize: '0.7rem', display: 'inline-flex', alignItems: 'center', gap: 3 }} title={t('Als JSON herunterladen')}>
<FaDownload /> JSON
</button>
)}
</div>
<_DataBlock data={inputData} />
<_FileLinkList files={inputFiles} />
@ -1271,8 +1343,13 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
)}
{hasOutput && (
<section>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem' }}>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem', display: 'flex', alignItems: 'center', gap: 6 }}>
{t('Output')}
{outputData !== undefined && outputData !== null && (
<button type="button" onClick={() => _downloadJson(outputData, `${step.nodeId}-output.json`)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--primary-color)', fontSize: '0.7rem', display: 'inline-flex', alignItems: 'center', gap: 3 }} title={t('Als JSON herunterladen')}>
<FaDownload /> JSON
</button>
)}
</div>
<_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]);

View file

@ -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<HTMLInputElement>(null);
const { showSuccess, showError } = useToast();
const { request } = useApiRequest();
const [viewMode, setViewMode] = useState<ViewMode>('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<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(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<HTMLDivElement>(null);
const _handleDividerPointerDown = useCallback((e: RPointerEvent<HTMLDivElement>) => {
e.preventDefault();
draggingRef.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const _handleDividerPointerMove = useCallback((e: RPointerEvent<HTMLDivElement>) => {
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: <FaLock />,
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: <FaLockOpen />,
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: <FaFileArchive />,
title: t('ZIP herunterladen'),
onClick: async () => {
try { await downloadGroupZip(groupId); }
catch (e) { showError(t('Fehler'), String(e)); }
},
disabled: itemIds.length === 0,
},
{
icon: <FaTrash />,
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<HTMLTableRowElement>, 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 (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<span className={styles.errorIcon}>&#9888;&#65039;</span>
<p className={styles.errorMessage}>{t('Fehler beim Laden der Dateien: {detail}', { detail: String(error) })}</p>
<button className={styles.secondaryButton} onClick={() => _tableRefetch()}>
<FaSync /> {t('Erneut versuchen')}
@ -288,89 +294,182 @@ export const FilesPage: React.FC = () => {
<p className={styles.pageSubtitle}>{t('Dateiverwaltung')}</p>
</div>
<div className={styles.headerActions}>
<div style={{ display: 'flex', gap: 4, marginRight: 8 }}>
<button
className={treeVisible ? styles.primaryButton : styles.secondaryButton}
onClick={() => { if (!treeVisible || tableVisible) setTreeVisible(v => !v); }}
style={{ fontSize: 12, padding: '4px 8px' }}
title={treeVisible ? t('Ordnerstruktur ausblenden') : t('Ordnerstruktur einblenden')}
>
<FaTree />
</button>
<button
className={tableVisible ? styles.primaryButton : styles.secondaryButton}
onClick={() => { if (!tableVisible || treeVisible) setTableVisible(v => !v); }}
style={{ fontSize: 12, padding: '4px 8px' }}
title={tableVisible ? t('Tabelle ausblenden') : t('Tabelle einblenden')}
>
<FaTable />
</button>
</div>
<button className={styles.secondaryButton} onClick={_refreshAll} disabled={tableLoading}>
<FaSync className={tableLoading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{
display: 'flex', gap: 8, padding: '8px 12px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
}}>
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> {uploadingFile ? t('Wird hochgeladen…') : t('Datei hochladen')}
</button>
)}
</div>
<div ref={splitContainerRef} style={{ flex: 1, overflow: 'hidden', display: 'flex', minHeight: 0 }}>
{/* Left panel: Tree */}
{treeVisible && (
<div style={{
width: tableVisible ? treeWidth : '100%',
flexShrink: 0,
overflow: 'auto',
display: 'flex', flexDirection: 'column',
}}>
<FormGeneratorTree
key={`own-${treeKey}`}
provider={provider}
ownership="own"
title={t('Eigene')}
showFilter={true}
onNodeClick={_handleTreeNodeClick}
onRefresh={() => _tableRefetch()}
/>
<FormGeneratorTree
key={`shared-${treeKey}`}
provider={provider}
ownership="shared"
title={t('Geteilt mit mir')}
collapsible={true}
defaultCollapsed={true}
emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleTreeNodeClick}
/>
</div>
)}
<div style={{ flex: 1, overflow: 'auto' }}>
<FormGeneratorTable
data={tableFiles || []}
columns={columns}
apiEndpoint="/api/files/list"
loading={tableLoading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
onRowSelect={(rows) => 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: <FaDownload />,
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 && (
<div
onPointerDown={_handleDividerPointerDown}
onPointerMove={_handleDividerPointerMove}
onPointerUp={_handleDividerPointerUp}
style={{
width: 6, cursor: 'col-resize', flexShrink: 0,
background: 'var(--color-border, #e0e0e0)',
position: 'relative', zIndex: 2,
touchAction: 'none',
}}
groupingConfig={{ contextKey: 'files/list', enabled: true }}
groupBulkActionsProvider={_groupBulkActionsProvider}
emptyMessage={t('Keine Dateien gefunden')}
/>
>
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 4, height: 32, borderRadius: 2,
background: 'var(--color-text-muted, #94a3b8)',
opacity: 0.4,
}} />
</div>
)}
{/* Right panel: Table with view-mode toggle */}
{tableVisible && (
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{
display: 'flex', gap: 8, padding: '8px 12px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
}}>
<button
className={viewMode === 'folder' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setViewMode('folder')}
style={{ fontSize: 12, padding: '4px 10px' }}
>
{t('Ordner-Sicht')}
</button>
<button
className={viewMode === 'all' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setViewMode('all')}
style={{ fontSize: 12, padding: '4px 10px' }}
>
{t('Alle Dateien')}
</button>
<div style={{ flex: 1 }} />
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')}
</button>
)}
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<FormGeneratorTable
data={tableFiles || []}
columns={columns}
apiEndpoint="/api/files/list"
loading={tableLoading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
onRowSelect={(rows) => 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: <FaDownload />,
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')}
/>
</div>
</div>
)}
</div>
{editingFile && (
@ -378,7 +477,7 @@ export const FilesPage: React.FC = () => {
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Datei bearbeiten')}</h2>
<button className={styles.modalClose} onClick={() => setEditingFile(null)}></button>
<button className={styles.modalClose} onClick={() => setEditingFile(null)}>&#10005;</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (