fixed component formgeneratortree and truastee workflows
This commit is contained in:
parent
3d580a5fca
commit
79557e51ed
29 changed files with 3558 additions and 2808 deletions
|
|
@ -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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 ? '' : '+');
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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: [] };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,930 @@
|
|||
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
FaChevronRight,
|
||||
FaUnlink,
|
||||
FaSyncAlt,
|
||||
} from 'react-icons/fa';
|
||||
import type {
|
||||
TreeNode,
|
||||
TreeNodeProvider,
|
||||
FormGeneratorTreeProps,
|
||||
Ownership,
|
||||
ScopeValue,
|
||||
TreeBatchAction,
|
||||
} from './types';
|
||||
import styles from './FormGeneratorTree.module.css';
|
||||
|
||||
const INDENT_PX = 24;
|
||||
const DRAG_MIME = 'application/x-poweron-tree-items';
|
||||
|
||||
const SCOPE_ORDER: ScopeValue[] = ['personal', 'featureInstance', 'mandate', 'global'];
|
||||
|
||||
const _SCOPE_EMOJIS: Record<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) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (node && !expandedIds.has(id)) {
|
||||
const childMap = _buildChildMap(nodes);
|
||||
const existingChildren = childMap.get(id);
|
||||
if (!existingChildren || existingChildren.length === 0) {
|
||||
const childNodes = await provider.loadChildren(id, ownership);
|
||||
if (childNodes.length > 0) {
|
||||
setNodes((prev) => [...prev, ...childNodes]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[nodes, expandedIds, provider, ownership],
|
||||
);
|
||||
|
||||
const _handleToggleSelect = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
const newSelection = new Set(selectedIds);
|
||||
|
||||
if (e.shiftKey && lastSelectedIdRef.current) {
|
||||
const visibleIds = flatEntries.map((entry) => entry.node.id);
|
||||
const lastIdx = visibleIds.indexOf(lastSelectedIdRef.current);
|
||||
const curIdx = visibleIds.indexOf(id);
|
||||
if (lastIdx !== -1 && curIdx !== -1) {
|
||||
const start = Math.min(lastIdx, curIdx);
|
||||
const end = Math.max(lastIdx, curIdx);
|
||||
for (let i = start; i <= end; i++) {
|
||||
newSelection.add(visibleIds[i]);
|
||||
}
|
||||
}
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
const descendantIds = _collectDescendantIds(id, nodes);
|
||||
for (const did of descendantIds) newSelection.delete(did);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
if (ownership === 'own') {
|
||||
const descendantIds = _collectDescendantIds(id, nodes);
|
||||
for (const did of descendantIds) newSelection.add(did);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const wasSelected = newSelection.has(id) && newSelection.size === 1;
|
||||
newSelection.clear();
|
||||
if (!wasSelected) {
|
||||
newSelection.add(id);
|
||||
if (ownership === 'own') {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (node?.type === 'folder') {
|
||||
const descendantIds = _collectDescendantIds(id, nodes);
|
||||
for (const did of descendantIds) newSelection.add(did);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastSelectedIdRef.current = id;
|
||||
_updateSelection(newSelection);
|
||||
},
|
||||
[selectedIds, flatEntries, nodes, ownership, _updateSelection],
|
||||
);
|
||||
|
||||
const _handleNodeClick = useCallback(
|
||||
(node: TreeNode<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}
|
||||
>
|
||||
×
|
||||
</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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
9
src/components/FormGenerator/FormGeneratorTree/index.ts
Normal file
9
src/components/FormGenerator/FormGeneratorTree/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { FormGeneratorTree } from './FormGeneratorTree';
|
||||
export type {
|
||||
TreeNode,
|
||||
TreeNodeProvider,
|
||||
TreeBatchAction,
|
||||
FormGeneratorTreeProps,
|
||||
Ownership,
|
||||
ScopeValue,
|
||||
} from './types';
|
||||
|
|
@ -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;
|
||||
64
src/components/FormGenerator/FormGeneratorTree/types.ts
Normal file
64
src/components/FormGenerator/FormGeneratorTree/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,18 @@
|
|||
import React, { useCallback, useRef, useMemo } from 'react';
|
||||
import { FaFileImport, FaPaperPlane } from 'react-icons/fa';
|
||||
import React, { useCallback, useRef, useMemo, useState } from 'react';
|
||||
import { FaFileImport } from 'react-icons/fa';
|
||||
import type { UdbContext } from './UnifiedDataBar';
|
||||
import api from '../../api';
|
||||
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import {
|
||||
importWorkflowFromFile,
|
||||
WORKFLOW_FILE_EXTENSION,
|
||||
} from '../../api/workflowApi';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { FormGeneratorTable } from '../FormGenerator/FormGeneratorTable';
|
||||
import { ViewActionButton } from '../FormGenerator/ActionButtons/ViewActionButton';
|
||||
import actionBtnStyles from '../FormGenerator/ActionButtons/ActionButton.module.css';
|
||||
import { FormGeneratorTree } from '../FormGenerator/FormGeneratorTree';
|
||||
import { createFolderFileProvider } from '../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
|
||||
import type { TreeNode } from '../FormGenerator/FormGeneratorTree';
|
||||
import styles from './FilesTab.module.css';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import type { TableGroupNode } from '../../api/connectionApi';
|
||||
|
||||
function _findGroupDisplayName(nodes: TableGroupNode[], groupId: string): string | null {
|
||||
for (const n of nodes) {
|
||||
if (n.id === groupId) return (n.name && n.name.trim()) || groupId;
|
||||
const sub = _findGroupDisplayName(n.subGroups, groupId);
|
||||
if (sub !== null) return sub;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface FilesTabProps {
|
||||
context: UdbContext;
|
||||
|
|
@ -38,23 +27,24 @@ const FilesTab: React.FC<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 +58,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 +96,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 +135,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 +168,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 +186,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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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}>⚠️</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)}>✕</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{formAttributes.length === 0 ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue