workign on folder location in file create node

This commit is contained in:
Ida 2026-05-06 08:37:45 +02:00
parent 1308e6d415
commit 9b0923b9da
7 changed files with 285 additions and 204 deletions

View file

@ -320,6 +320,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'featureInstance', 'featureInstance',
'sharepointFolder', 'sharepointFolder',
'sharepointFile', 'sharepointFile',
'userFileFolder',
'clickupList', 'clickupList',
'clickupTask', 'clickupTask',
'dataRef', 'dataRef',

View file

@ -0,0 +1,115 @@
/**
* userFileFolder same folder tree as Meine Dateien (FormGeneratorTree) inside a collapsible panel.
*/
import React, { useMemo, useCallback, useState } from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree';
import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree';
import type { FieldRendererProps } from './index';
export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, request }) => {
const { t } = useLanguage();
const [panelOpen, setPanelOpen] = useState(true);
const provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []);
const strVal = typeof value === 'string' ? value : '';
const rootSelected = strVal === '';
const handleNodeClick = useCallback(
(node: TreeNode) => {
if (node.type === 'folder') {
onChange(node.id);
}
},
[onChange],
);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{!request && (
<div style={{ fontSize: 11, color: '#888' }}>{t('Ordnerliste nicht verfügbar (keine API-Anbindung).')}</div>
)}
{request && (
<>
<button
type="button"
onClick={() => setPanelOpen((o) => !o)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '6px 10px',
marginBottom: panelOpen ? 6 : 0,
borderRadius: 6,
border: '1px solid var(--color-border, #cbd5e1)',
background: 'var(--table-header-bg, #f1f5f9)',
cursor: 'pointer',
fontSize: 12,
textAlign: 'left',
color: 'var(--color-text, #334155)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{panelOpen ? t('Ordnerbaum ausblenden') : t('Ordnerbaum einblenden')}
</span>
<span aria-hidden style={{ marginLeft: 8, flexShrink: 0 }}>
{panelOpen ? '▾' : '▸'}
</span>
</button>
{panelOpen && (
<div
style={{
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: 8,
overflow: 'hidden',
background: 'var(--color-bg, #fff)',
}}
>
<div
role="button"
tabIndex={0}
onClick={() => onChange('')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange('');
}
}}
style={{
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
borderBottom: '1px solid var(--color-border, #e2e8f0)',
background: rootSelected
? 'rgba(37, 99, 235, 0.12)'
: 'var(--table-header-bg, #f8fafc)',
}}
>
{t('Stamm — Meine Dateien')}
</div>
<FormGeneratorTree
provider={provider}
ownership="own"
title={t('Ordner')}
compact
allowCreateFolder
showFilter={false}
emptyMessage={t('Noch keine Ordner')}
onNodeClick={handleNodeClick}
embedMaxHeight={260}
hideRowActionButtons
/>
</div>
)}
</>
)}
</div>
);
};

View file

@ -34,6 +34,7 @@ import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer'; import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer'; import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { FeatureInstancePicker } from './FeatureInstancePicker'; import { FeatureInstancePicker } from './FeatureInstancePicker';
import { UserFileFolderPicker } from './UserFileFolderPicker';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer'; import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config'; import { getApiBaseUrl } from '../../../../../config/config';
@ -917,6 +918,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
featureInstance: FeatureInstancePicker, featureInstance: FeatureInstancePicker,
sharepointFolder: SharepointPathPicker, sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker, sharepointFile: SharepointPathPicker,
userFileFolder: UserFileFolderPicker,
clickupList: FolderPicker, clickupList: FolderPicker,
clickupTask: FolderPicker, clickupTask: FolderPicker,
caseList: CaseListEditor, caseList: CaseListEditor,

View file

@ -543,6 +543,22 @@
line-height: 1.5; line-height: 1.5;
} }
/* Embedded workflow / compact pickers — fixed height so flex children (treeWrapper) get a real viewport */
.embeddedPicker {
display: flex;
flex-direction: column;
flex: none !important;
min-height: 0;
overflow: hidden;
/* height + maxHeight set inline (embedMaxHeight) */
}
.embeddedPicker .treeWrapper {
flex: 1 1 0;
min-height: 0;
max-height: none;
}
/* Compact mode */ /* Compact mode */
.compactMode .sectionHeader { .compactMode .sectionHeader {
padding: 6px 8px; padding: 6px 8px;

View file

@ -180,6 +180,7 @@ interface TreeNodeRowProps<T = any> {
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void; onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
onDragLeave: (e: React.DragEvent) => void; onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent, node: TreeNode<T>) => void; onDrop: (e: React.DragEvent, node: TreeNode<T>) => void;
hideRowActionButtons?: boolean;
} }
const TreeNodeRow = React.memo(function TreeNodeRow<T>({ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
@ -213,6 +214,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
onDragOver, onDragOver,
onDragLeave, onDragLeave,
onDrop, onDrop,
hideRowActionButtons = false,
}: TreeNodeRowProps<T>) { }: TreeNodeRowProps<T>) {
const { node, depth, hasChildren } = entry; const { node, depth, hasChildren } = entry;
const renameRef = useRef<HTMLInputElement>(null); const renameRef = useRef<HTMLInputElement>(null);
@ -246,11 +248,12 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
const _handleDoubleClick = useCallback( const _handleDoubleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (hideRowActionButtons) return;
if (ownership === 'own' && provider.canRename?.(node)) { if (ownership === 'own' && provider.canRename?.(node)) {
onStartRename(node.id); onStartRename(node.id);
} }
}, },
[ownership, provider, node, onStartRename], [hideRowActionButtons, ownership, provider, node, onStartRename],
); );
const _handleRowClick = useCallback( const _handleRowClick = useCallback(
@ -298,11 +301,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
className={rowClasses} className={rowClasses}
onClick={_handleRowClick} onClick={_handleRowClick}
onDoubleClick={_handleDoubleClick} onDoubleClick={_handleDoubleClick}
draggable draggable={!hideRowActionButtons}
onDragStart={(e) => onDragStart(e, node)} onDragStart={hideRowActionButtons ? undefined : (e) => onDragStart(e, node)}
onDragOver={(e) => onDragOver(e, node)} onDragOver={hideRowActionButtons ? undefined : (e) => onDragOver(e, node)}
onDragLeave={onDragLeave} onDragLeave={hideRowActionButtons ? undefined : onDragLeave}
onDrop={(e) => onDrop(e, node)} onDrop={hideRowActionButtons ? undefined : (e) => onDrop(e, node)}
data-node-id={node.id} data-node-id={node.id}
title={node.name} title={node.name}
role="treeitem" role="treeitem"
@ -312,7 +315,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
> >
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} /> <div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
{selectable && ( {!hideRowActionButtons && (
<input <input
type="checkbox" type="checkbox"
className={styles.nodeCheckbox} className={styles.nodeCheckbox}
@ -361,27 +364,22 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</span> </span>
)} )}
<div className={styles.nodeSizeGroup}> {!hideRowActionButtons && (
<span className={styles.nodeSize}> <span className={styles.nodeSize}>
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''} {node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
</span> </span>
<div className={styles.nodeActionsHover}>
{canCreateChild && onCreateChild && (
<button
className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onCreateChild(node.id); }}
title="Neuer Unterordner"
tabIndex={-1}
>
{'\u2795'}
</button>
)} )}
{!hideRowActionButtons && (
<>
<div className={styles.nodeActionsHover}>
{canRename && ( {canRename && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onStartRename(node.id); }} onClick={(e) => {
e.stopPropagation();
onStartRename(node.id);
}}
title="Umbenennen" title="Umbenennen"
tabIndex={-1} tabIndex={-1}
> >
@ -389,10 +387,13 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</button> </button>
)} )}
{node.type !== 'folder' && provider.downloadNode && ( {node.type !== 'folder' && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onDownload(node); }} onClick={(e) => {
e.stopPropagation();
onDownload(node);
}}
title="Datei herunterladen" title="Datei herunterladen"
tabIndex={-1} tabIndex={-1}
> >
@ -403,7 +404,10 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
{canDelete && ( {canDelete && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onDelete(node.id); }} onClick={(e) => {
e.stopPropagation();
onDelete(node.id);
}}
title="Loeschen" title="Loeschen"
tabIndex={-1} tabIndex={-1}
> >
@ -411,52 +415,8 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</button> </button>
)} )}
</div> </div>
</div>
<div className={styles.nodeActionsPersistent}> <div className={styles.nodeActionsPersistent}>
{/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */}
{node.extraActions?.map((action) => (
<button
key={action.key}
className={`${styles.emojiBtn} ${action.disabled ? styles.emojiBtnReadonly : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!action.disabled) onExtraAction(node.id, action);
}}
title={action.tooltip}
tabIndex={-1}
disabled={action.disabled}
>
{pendingActions.has(action.key)
? <span className={styles.flagSpinner} />
: action.value === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: action.icon}
</button>
))}
{node.ragIndexEnabled !== undefined && (
<button
className={`${styles.emojiBtn} ${canPatchRagIndex ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => {
e.stopPropagation();
if (canPatchRagIndex) onToggleRagIndex(node);
}}
title={node.ragIndexEnabled === 'mixed'
? 'Gemischt - Klick setzt explizit'
: node.ragIndexEnabled ? 'RAG-Indexierung an' : 'RAG-Indexierung aus'}
tabIndex={-1}
>
{pendingActions.has(_ACTION_RAG)
? <span className={styles.flagSpinner} />
: node.ragIndexEnabled === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: node.ragIndexEnabled === true
? _RAG_ON_EMOJI
: <span style={_OFF_STATE_STYLE}>{_RAG_OFF_EMOJI}</span>}
</button>
)}
{onSendToChat && ( {onSendToChat && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
@ -478,14 +438,10 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
e.stopPropagation(); e.stopPropagation();
if (canPatchScope) onCycleScope(node); if (canPatchScope) onCycleScope(node);
}} }}
title={node.scope === 'mixed' ? 'Gemischt - Klick setzt explizit' : `Scope: ${node.scope}`} title={`Scope: ${node.scope}`}
tabIndex={-1} tabIndex={-1}
> >
{pendingActions.has(_ACTION_SCOPE) {_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
? <span className={styles.flagSpinner} />
: node.scope === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: (_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal)}
</button> </button>
)} )}
@ -496,21 +452,16 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
e.stopPropagation(); e.stopPropagation();
if (canPatchNeutralize) onToggleNeutralize(node); if (canPatchNeutralize) onToggleNeutralize(node);
}} }}
title={node.neutralize === 'mixed' title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
? 'Gemischt - Klick setzt explizit'
: node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
tabIndex={-1} tabIndex={-1}
style={{ opacity: node.neutralize ? 1 : 0.35 }}
> >
{pendingActions.has(_ACTION_NEUTRALIZE) {_NEUTRALIZE_EMOJI}
? <span className={styles.flagSpinner} />
: node.neutralize === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: node.neutralize === true
? _NEUTRALIZE_ON_EMOJI
: <span style={_OFF_STATE_STYLE}>{_NEUTRALIZE_OFF_EMOJI}</span>}
</button> </button>
)} )}
</div> </div>
</>
)}
</div> </div>
); );
}) as <T>(props: TreeNodeRowProps<T>) => React.ReactElement; }) as <T>(props: TreeNodeRowProps<T>) => React.ReactElement;
@ -532,6 +483,8 @@ export function FormGeneratorTree<T = any>({
selectable = true, selectable = true,
refreshAfterAction = false, refreshAfterAction = false,
className, className,
embedMaxHeight,
hideRowActionButtons = false,
}: FormGeneratorTreeProps<T>) { }: FormGeneratorTreeProps<T>) {
const { t } = useLanguage(); const { t } = useLanguage();
const { confirm, ConfirmDialog } = useConfirm(); const { confirm, ConfirmDialog } = useConfirm();
@ -1097,6 +1050,7 @@ export function FormGeneratorTree<T = any>({
} }
case 'F2': { case 'F2': {
e.preventDefault(); e.preventDefault();
if (hideRowActionButtons) break;
const node = nodes.find((n) => n.id === focusedId); const node = nodes.find((n) => n.id === focusedId);
if (node && ownership === 'own' && provider.canRename?.(node)) { if (node && ownership === 'own' && provider.canRename?.(node)) {
_handleStartRename(focusedId); _handleStartRename(focusedId);
@ -1105,6 +1059,7 @@ export function FormGeneratorTree<T = any>({
} }
case 'Delete': { case 'Delete': {
e.preventDefault(); e.preventDefault();
if (hideRowActionButtons) break;
const node = nodes.find((n) => n.id === focusedId); const node = nodes.find((n) => n.id === focusedId);
if (node && ownership === 'own' && provider.canDelete?.(node)) { if (node && ownership === 'own' && provider.canDelete?.(node)) {
_handleDelete(focusedId); _handleDelete(focusedId);
@ -1124,6 +1079,7 @@ export function FormGeneratorTree<T = any>({
_handleToggleSelect, _handleToggleSelect,
_handleStartRename, _handleStartRename,
_handleDelete, _handleDelete,
hideRowActionButtons,
], ],
); );
@ -1163,13 +1119,21 @@ export function FormGeneratorTree<T = any>({
const wrapperClasses = [ const wrapperClasses = [
styles.formGeneratorTree, styles.formGeneratorTree,
compact && styles.compactMode, compact && styles.compactMode,
embedMaxHeight != null && styles.embeddedPicker,
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
return ( return (
<div className={wrapperClasses}> <div
className={wrapperClasses}
style={
embedMaxHeight != null
? { height: embedMaxHeight, maxHeight: embedMaxHeight, flexShrink: 0 }
: undefined
}
>
{title && ( {title && (
<div <div
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`} className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
@ -1236,7 +1200,11 @@ export function FormGeneratorTree<T = any>({
</div> </div>
)} )}
<<<<<<< HEAD
{selectable && selectedIds.size > 0 && batchActions.length > 0 && ( {selectable && selectedIds.size > 0 && batchActions.length > 0 && (
=======
{selectedIds.size > 0 && batchActions.length > 0 && !hideRowActionButtons && (
>>>>>>> 7fb9645 (workign on folder location in file create node)
<div className={styles.batchToolbar}> <div className={styles.batchToolbar}>
<span className={styles.batchCount}>{selectedIds.size} selected</span> <span className={styles.batchCount}>{selectedIds.size} selected</span>
{batchActions.map((action: TreeBatchAction) => { {batchActions.map((action: TreeBatchAction) => {
@ -1318,6 +1286,7 @@ export function FormGeneratorTree<T = any>({
onDragOver={_handleDragOver} onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave} onDragLeave={_handleDragLeave}
onDrop={_handleDrop} onDrop={_handleDrop}
hideRowActionButtons={hideRowActionButtons}
/> />
)) ))
)} )}

View file

@ -52,34 +52,8 @@ function _mapFileToNode(file: FileData, ownership: Ownership): TreeNode {
}; };
} }
/** Stable synthetic root id per ownership scope. The real top-level export function createFolderFileProvider(options: { includeFiles?: boolean } = {}): TreeNodeProvider {
* folders/files attach their `parentId` to this id once we re-parent them const includeFiles = options.includeFiles !== false;
* in `loadChildren`. The id stays inside the FE provider; the backend
* never sees it. */
const _SYNTH_ROOT_ID = (ownership: Ownership): string => `__filesRoot:${ownership}`;
/** Build the synthetic root node. Its only job is to:
* - act as a drop-target for moving items back to top-level,
* - expose a global neutralize/scope toggle that cascades to every
* top-level descendant.
* Its scope/neutralize values are intentionally `undefined` (= "no own
* state") the icons render an indeterminate state and a click sets the
* intent on every owned descendant. */
function _makeSyntheticRoot(ownership: Ownership): TreeNode {
return {
id: _SYNTH_ROOT_ID(ownership),
name: '/',
type: 'folder',
parentId: null,
ownership,
icon: <FaFolder style={{ color: '#666' }} />,
defaultExpanded: true,
scope: 'personal',
neutralize: false,
};
}
export function createFolderFileProvider(): TreeNodeProvider {
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared'); const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
const typeMap = new Map<string, 'folder' | 'file'>(); const typeMap = new Map<string, 'folder' | 'file'>();
@ -157,10 +131,11 @@ export function createFolderFileProvider(): TreeNodeProvider {
} }
nodes.push(...folderNodes); nodes.push(...folderNodes);
if (includeFiles) {
try { try {
const filters: Record<string, any> = {}; const filters: Record<string, any> = {};
if (apiParentId) { if (parentId) {
filters.folderId = apiParentId; filters.folderId = parentId;
} }
const paginationParam = JSON.stringify({ filters, pageSize: 500 }); const paginationParam = JSON.stringify({ filters, pageSize: 500 });
const filesRes = await api.get('/api/files/list', { const filesRes = await api.get('/api/files/list', {
@ -173,19 +148,16 @@ export function createFolderFileProvider(): TreeNodeProvider {
} else if (Array.isArray(data)) { } else if (Array.isArray(data)) {
rawFiles = data; rawFiles = data;
} }
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId); let matched = rawFiles.filter((f) => (f.folderId ?? null) === parentId);
if (ownership === 'shared') { if (ownership === 'shared') {
const myId = getUserDataCache()?.id; const myId = getUserDataCache()?.id;
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId); if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
} }
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership)); nodes.push(...matched.map((f) => _mapFileToNode(f, ownership)));
if (apiParentId === null) {
for (const n of fileNodes) n.parentId = synthRootId;
}
nodes.push(...fileNodes);
} catch { } catch {
// file list may fail for shared trees; folders still render // file list may fail for shared trees; folders still render
} }
}
_trackTypes(nodes); _trackTypes(nodes);
return nodes; return nodes;

View file

@ -122,4 +122,10 @@ export interface FormGeneratorTreeProps<T = any> {
* that rely on the optimistic-update path. */ * that rely on the optimistic-update path. */
refreshAfterAction?: boolean; refreshAfterAction?: boolean;
className?: string; className?: string;
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
embedMaxHeight?: number;
/**
* Hides checkbox, size column, per-row emoji actions, drag-drop, and batch toolbar saves space in pickers.
*/
hideRowActionButtons?: boolean;
} }