finished file tree folder selection in file create node
This commit is contained in:
parent
9b0923b9da
commit
25abb6fff9
4 changed files with 284 additions and 128 deletions
|
|
@ -1,9 +1,12 @@
|
|||
/**
|
||||
* userFileFolder — same folder tree as Meine Dateien (FormGeneratorTree) inside a collapsible panel.
|
||||
* userFileFolder — FormGeneratorTree embedded: combobox-style trigger + expandable tree.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback, useState } from 'react';
|
||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import { FaFolderPlus } from 'react-icons/fa';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { usePrompt } from '../../../../hooks/usePrompt';
|
||||
import { getFolderTree, createFolder } from '../../../../api/fileApi';
|
||||
import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree';
|
||||
import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
|
||||
import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree';
|
||||
|
|
@ -11,22 +14,82 @@ import type { FieldRendererProps } from './index';
|
|||
|
||||
export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, request }) => {
|
||||
const { t } = useLanguage();
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const { prompt, PromptDialog } = usePrompt();
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
/** Remount embedded tree after create/rename elsewhere */
|
||||
const [treeRefreshKey, setTreeRefreshKey] = useState(0);
|
||||
const [creating, setCreating] = useState(false);
|
||||
/** Display name for saved folderId (resolved from API when graph loads). */
|
||||
const [pickedName, setPickedName] = useState<string | null>(null);
|
||||
|
||||
const provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []);
|
||||
|
||||
const strVal = typeof value === 'string' ? value : '';
|
||||
const rootSelected = strVal === '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!strVal) {
|
||||
setPickedName(null);
|
||||
return;
|
||||
}
|
||||
if (!request) return;
|
||||
let cancelled = false;
|
||||
getFolderTree(request, 'me')
|
||||
.then((folders) => {
|
||||
if (cancelled) return;
|
||||
const f = folders.find((x) => x.id === strVal);
|
||||
setPickedName(f?.name ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPickedName(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [strVal, request]);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(node: TreeNode) => {
|
||||
if (node.type === 'folder') {
|
||||
setPickedName(node.name);
|
||||
onChange(node.id);
|
||||
setPanelOpen(false);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const clearFolder = useCallback(() => {
|
||||
onChange('');
|
||||
setPickedName(null);
|
||||
}, [onChange]);
|
||||
|
||||
const triggerLabel = strVal ? (pickedName ?? '…') : t('Wähle einen Zielordner');
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!request || creating) return;
|
||||
const parentHint = strVal && pickedName ? ` („${pickedName}“)` : strVal ? '' : ' (Stamm)';
|
||||
const entered = await prompt(`Ordnername${parentHint}:`, {
|
||||
title: 'Neuer Ordner',
|
||||
placeholder: 'Ordnername',
|
||||
confirmLabel: t('Anlegen'),
|
||||
});
|
||||
const trimmed = entered?.trim();
|
||||
if (!trimmed) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const parentId = strVal || null;
|
||||
const folder = await createFolder(request, trimmed, parentId);
|
||||
setPickedName(folder.name);
|
||||
onChange(folder.id);
|
||||
setTreeRefreshKey((k) => k + 1);
|
||||
} catch {
|
||||
// stay silent in minimal UI; devtools / global handler may log
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [request, creating, strVal, pickedName, prompt, onChange, t]);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
|
|
@ -35,32 +98,71 @@ export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, valu
|
|||
)}
|
||||
{request && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelOpen((o) => !o)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '6px 10px',
|
||||
marginBottom: panelOpen ? 6 : 0,
|
||||
alignItems: 'stretch',
|
||||
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)',
|
||||
overflow: 'hidden',
|
||||
marginBottom: panelOpen ? 6 : 0,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelOpen((o) => !o)}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
padding: '8px 10px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
textAlign: 'left',
|
||||
color: 'var(--color-text, #334155)',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
|
||||
{panelOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
{strVal ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t('Zielordner entfernen (Stamm — Meine Dateien)')}
|
||||
aria-label={t('Zielordner entfernen')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearFolder();
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 36,
|
||||
border: 'none',
|
||||
borderLeft: '1px solid var(--color-border, #cbd5e1)',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: 16,
|
||||
lineHeight: 1,
|
||||
color: 'var(--color-text-secondary, #64748b)',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{panelOpen && (
|
||||
<div
|
||||
|
|
@ -72,42 +174,92 @@ export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, valu
|
|||
}}
|
||||
>
|
||||
<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',
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
borderBottom: '1px solid var(--color-border, #e2e8f0)',
|
||||
background: rootSelected
|
||||
? 'rgba(37, 99, 235, 0.12)'
|
||||
: 'var(--table-header-bg, #f8fafc)',
|
||||
background: 'var(--table-header-bg, #f8fafc)',
|
||||
}}
|
||||
>
|
||||
{t('Stamm — Meine Dateien')}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
clearFolder();
|
||||
setPanelOpen(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
clearFolder();
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 36,
|
||||
background: rootSelected ? 'rgba(37, 99, 235, 0.12)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{t('Stamm — Meine Dateien')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('Neuen Ordner erstellen')}
|
||||
title={
|
||||
creating
|
||||
? t('Wird angelegt…')
|
||||
: strVal
|
||||
? `Unterordner von: ${pickedName ?? '…'}`
|
||||
: 'Unter dem Stamm (oberste Ebene)'
|
||||
}
|
||||
disabled={creating}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleCreateFolder();
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 40,
|
||||
minHeight: 36,
|
||||
alignSelf: 'stretch',
|
||||
border: 'none',
|
||||
borderLeft: '1px solid var(--color-border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
cursor: creating ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--primary-color, #2563eb)',
|
||||
opacity: creating ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<FaFolderPlus size={14} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<FormGeneratorTree
|
||||
key={`user-folder-tree-${treeRefreshKey}`}
|
||||
provider={provider}
|
||||
ownership="own"
|
||||
title={t('Ordner')}
|
||||
compact
|
||||
allowCreateFolder
|
||||
allowCreateFolder={false}
|
||||
showFilter={false}
|
||||
emptyMessage={t('Noch keine Ordner')}
|
||||
onNodeClick={handleNodeClick}
|
||||
embedMaxHeight={260}
|
||||
embedMaxHeight={240}
|
||||
hideRowActionButtons
|
||||
hideSectionHeader
|
||||
enableDragDrop
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PromptDialog />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
|
|||
function _flatten<T>(
|
||||
nodes: TreeNode<T>[],
|
||||
expandedIds: Set<string>,
|
||||
confirmedEmptyFolderIds: Set<string>,
|
||||
): FlatEntry<T>[] {
|
||||
const childMap = _buildChildMap(nodes);
|
||||
const result: FlatEntry<T>[] = [];
|
||||
|
|
@ -112,8 +113,22 @@ function _flatten<T>(
|
|||
const children = childMap.get(parentKey);
|
||||
if (!children) return;
|
||||
for (const node of children) {
|
||||
const nodeChildren = childMap.get(node.id);
|
||||
const hasChildren = (nodeChildren && nodeChildren.length > 0) || node.type === 'folder';
|
||||
const loadedChildren = childMap.get(node.id) ?? [];
|
||||
const hasLoadedKids = loadedChildren.length > 0;
|
||||
let hasChildren = false;
|
||||
|
||||
if (node.type !== 'folder') {
|
||||
hasChildren = hasLoadedKids;
|
||||
} else if (hasLoadedKids) {
|
||||
hasChildren = true;
|
||||
} else if (confirmedEmptyFolderIds.has(node.id)) {
|
||||
hasChildren = false;
|
||||
} else if (node.hasSubfoldersInApiTree === false && node.mayHaveLazyFileChildren === false) {
|
||||
hasChildren = false;
|
||||
} else {
|
||||
hasChildren = true;
|
||||
}
|
||||
|
||||
result.push({ node, depth, hasChildren });
|
||||
if (hasChildren && expandedIds.has(node.id)) {
|
||||
_walk(node.id, depth + 1);
|
||||
|
|
@ -181,6 +196,7 @@ interface TreeNodeRowProps<T = any> {
|
|||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent, node: TreeNode<T>) => void;
|
||||
hideRowActionButtons?: boolean;
|
||||
dragDropEnabled?: boolean;
|
||||
}
|
||||
|
||||
const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||
|
|
@ -215,6 +231,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
onDragLeave,
|
||||
onDrop,
|
||||
hideRowActionButtons = false,
|
||||
dragDropEnabled = true,
|
||||
}: TreeNodeRowProps<T>) {
|
||||
const { node, depth, hasChildren } = entry;
|
||||
const renameRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -301,11 +318,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
className={rowClasses}
|
||||
onClick={_handleRowClick}
|
||||
onDoubleClick={_handleDoubleClick}
|
||||
draggable={!hideRowActionButtons}
|
||||
onDragStart={hideRowActionButtons ? undefined : (e) => onDragStart(e, node)}
|
||||
onDragOver={hideRowActionButtons ? undefined : (e) => onDragOver(e, node)}
|
||||
onDragLeave={hideRowActionButtons ? undefined : onDragLeave}
|
||||
onDrop={hideRowActionButtons ? undefined : (e) => onDrop(e, node)}
|
||||
draggable={dragDropEnabled}
|
||||
onDragStart={dragDropEnabled ? (e) => onDragStart(e, node) : undefined}
|
||||
onDragOver={dragDropEnabled ? (e) => onDragOver(e, node) : undefined}
|
||||
onDragLeave={dragDropEnabled ? onDragLeave : undefined}
|
||||
onDrop={dragDropEnabled ? (e) => onDrop(e, node) : undefined}
|
||||
data-node-id={node.id}
|
||||
title={node.name}
|
||||
role="treeitem"
|
||||
|
|
@ -485,6 +502,8 @@ export function FormGeneratorTree<T = any>({
|
|||
className,
|
||||
embedMaxHeight,
|
||||
hideRowActionButtons = false,
|
||||
hideSectionHeader = false,
|
||||
enableDragDrop,
|
||||
}: FormGeneratorTreeProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
|
@ -499,8 +518,8 @@ export function FormGeneratorTree<T = any>({
|
|||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
|
||||
const [filterText, setFilterText] = useState('');
|
||||
/** Map of nodeId -> set of action keys currently pending (for spinner rendering). */
|
||||
const [pendingActions, setPendingActions] = useState<Map<string, Set<string>>>(new Map());
|
||||
/** Folders we expanded and confirmed have no visible children → hide chevron like a real leaf */
|
||||
const [confirmedEmptyFolderIds, setConfirmedEmptyFolderIds] = useState(() => new Set<string>());
|
||||
const lastSelectedIdRef = useRef<string | null>(null);
|
||||
const treeContentRef = useRef<HTMLDivElement>(null);
|
||||
/** Tracks node ids for which auto-expand has already fired (one-shot). */
|
||||
|
|
@ -597,6 +616,7 @@ export function FormGeneratorTree<T = any>({
|
|||
autoExpandedRef.current.clear();
|
||||
setExpandedIds(new Set());
|
||||
try {
|
||||
setConfirmedEmptyFolderIds(new Set());
|
||||
const rootNodes = await provider.loadChildren(null, ownership);
|
||||
setNodes(rootNodes);
|
||||
if (defaultCollapsed && rootNodes.length === 0) {
|
||||
|
|
@ -611,46 +631,10 @@ export function FormGeneratorTree<T = any>({
|
|||
_loadRoot();
|
||||
}, [_loadRoot]);
|
||||
|
||||
/** Auto-expand nodes with `defaultExpanded=true` from backend, one-shot per id.
|
||||
* Fetches children first, then sets expandedIds + merges atomically so the
|
||||
* expanded arrow never appears without visible children. */
|
||||
useEffect(() => {
|
||||
const targets = nodes.filter(
|
||||
(n) => n.defaultExpanded === true && !autoExpandedRef.current.has(n.id),
|
||||
);
|
||||
if (targets.length === 0) return;
|
||||
const targetIds = targets.map((t) => t.id);
|
||||
for (const id of targetIds) autoExpandedRef.current.add(id);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const childMap = _buildChildMap(nodes);
|
||||
const toFetch = targetIds.filter((id) => {
|
||||
const existing = childMap.get(id);
|
||||
return !existing || existing.length === 0;
|
||||
});
|
||||
if (toFetch.length > 0) {
|
||||
const results = await Promise.all(
|
||||
toFetch.map((id) =>
|
||||
provider.loadChildren(id, ownership).catch(() => [] as TreeNode<T>[]),
|
||||
),
|
||||
);
|
||||
if (cancelled) return;
|
||||
const flat = results.flat();
|
||||
if (flat.length > 0) {
|
||||
setNodes((prev) => _mergeNodes(prev, flat));
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of targetIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [nodes, provider, ownership, _mergeNodes]);
|
||||
|
||||
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]);
|
||||
const flatEntriesRaw = useMemo(
|
||||
() => _flatten(nodes, expandedIds, confirmedEmptyFolderIds),
|
||||
[nodes, expandedIds, confirmedEmptyFolderIds],
|
||||
);
|
||||
|
||||
const flatEntries = useMemo(() => {
|
||||
const term = filterText.trim().toLowerCase();
|
||||
|
|
@ -684,15 +668,21 @@ export function FormGeneratorTree<T = any>({
|
|||
async (id: string) => {
|
||||
const wasExpanded = expandedIds.has(id);
|
||||
|
||||
if (wasExpanded) {
|
||||
// Collapse: remove all descendants from nodes state and expandedIds.
|
||||
const descendantIds = new Set<string>();
|
||||
const _collectDescendants = (parentId: string) => {
|
||||
for (const n of nodes) {
|
||||
if (n.parentId === parentId && !descendantIds.has(n.id)) {
|
||||
descendantIds.add(n.id);
|
||||
_collectDescendants(n.id);
|
||||
}
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (node && !wasExpanded) {
|
||||
const childMap = _buildChildMap(nodes);
|
||||
const existingChildren = childMap.get(id);
|
||||
if (!existingChildren || existingChildren.length === 0) {
|
||||
const childNodes = await provider.loadChildren(id, ownership);
|
||||
if (childNodes.length > 0) {
|
||||
setNodes((prev) => [...prev, ...childNodes]);
|
||||
setConfirmedEmptyFolderIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} else if (node.type === 'folder') {
|
||||
setConfirmedEmptyFolderIds((prev) => new Set(prev).add(id));
|
||||
}
|
||||
};
|
||||
_collectDescendants(id);
|
||||
|
|
@ -828,6 +818,7 @@ export function FormGeneratorTree<T = any>({
|
|||
if (!trimmed) return;
|
||||
try {
|
||||
const newNode = await provider.createChild(parentId, trimmed);
|
||||
<<<<<<< HEAD
|
||||
setNodes((prev) => _mergeNodes(prev, [newNode]));
|
||||
// The provider may have re-parented `newNode` (e.g. onto a synth-root)
|
||||
// when `parentId === null`; expand whichever parent the resulting node
|
||||
|
|
@ -835,6 +826,16 @@ export function FormGeneratorTree<T = any>({
|
|||
const visibleParent = newNode.parentId ?? null;
|
||||
if (visibleParent) {
|
||||
setExpandedIds((prev) => new Set(prev).add(visibleParent));
|
||||
=======
|
||||
setNodes((prev) => [...prev, newNode]);
|
||||
if (parentId) {
|
||||
setConfirmedEmptyFolderIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(parentId);
|
||||
return next;
|
||||
});
|
||||
setExpandedIds((prev) => new Set(prev).add(parentId));
|
||||
>>>>>>> ae63020 (finished file tree folder selection in file create node)
|
||||
}
|
||||
} catch {
|
||||
await _handleRefresh();
|
||||
|
|
@ -1095,6 +1096,8 @@ export function FormGeneratorTree<T = any>({
|
|||
);
|
||||
}, [provider, ownership]);
|
||||
|
||||
const dragDropEnabled = enableDragDrop ?? !hideRowActionButtons;
|
||||
|
||||
const _filteredIdsForAction = useCallback(
|
||||
(action: TreeBatchAction): string[] => {
|
||||
const ids = [...selectedIds];
|
||||
|
|
@ -1134,7 +1137,7 @@ export function FormGeneratorTree<T = any>({
|
|||
: undefined
|
||||
}
|
||||
>
|
||||
{title && (
|
||||
{title && !hideSectionHeader && (
|
||||
<div
|
||||
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
|
||||
onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined}
|
||||
|
|
@ -1287,6 +1290,7 @@ export function FormGeneratorTree<T = any>({
|
|||
onDragLeave={_handleDragLeave}
|
||||
onDrop={_handleDrop}
|
||||
hideRowActionButtons={hideRowActionButtons}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ interface FileData {
|
|||
sysCreatedBy?: string;
|
||||
}
|
||||
|
||||
function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode {
|
||||
function _mapFolderToNode(folder: FolderData, ownership: Ownership, allFolders: FolderData[], includeFilesInTree: boolean): TreeNode {
|
||||
const hasSubfoldersInApiTree = allFolders.some((f) => (f.parentId ?? null) === folder.id);
|
||||
const mayHaveLazyFileChildren = includeFilesInTree && !hasSubfoldersInApiTree;
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
|
|
@ -34,6 +36,8 @@ function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode {
|
|||
neutralize: folder.neutralize,
|
||||
contextOrphan: folder.contextOrphan,
|
||||
icon: <FaFolder />,
|
||||
hasSubfoldersInApiTree,
|
||||
mayHaveLazyFileChildren,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -123,13 +127,8 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
|||
|
||||
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
|
||||
const allFolders: FolderData[] = foldersRes.data ?? [];
|
||||
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === apiParentId);
|
||||
const folderNodes = childFolders.map((f) => _mapFolderToNode(f, ownership));
|
||||
// Re-parent top-level folders onto the synthetic root.
|
||||
if (apiParentId === null) {
|
||||
for (const n of folderNodes) n.parentId = synthRootId;
|
||||
}
|
||||
nodes.push(...folderNodes);
|
||||
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
|
||||
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership, allFolders, includeFiles)));
|
||||
|
||||
if (includeFiles) {
|
||||
try {
|
||||
|
|
@ -198,22 +197,9 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
|||
},
|
||||
|
||||
async createChild(parentId, name) {
|
||||
// Creating a folder under "/" means a top-level folder; map back to null
|
||||
// for the API. The FE-only synth-root id never travels to the backend.
|
||||
const apiParentId = parentId && parentId.startsWith('__filesRoot:') ? null : parentId;
|
||||
const res = await api.post('/api/files/folders', { name, parentId: apiParentId });
|
||||
const node = _mapFolderToNode(res.data, 'own');
|
||||
// Bind the new folder visually to the parent the user actually clicked.
|
||||
// - explicit synth-root parentId -> attach there ("/" + new top-level folder)
|
||||
// - explicit parent (real folder) -> the API echoes the same parentId
|
||||
// - parentId === null (no clicked parent, e.g. global "+" with no
|
||||
// selection): default to the OWN tree's synth-root so the new folder
|
||||
// shows up inside "/" instead of at the legacy top-level row.
|
||||
if (parentId && parentId.startsWith('__filesRoot:')) {
|
||||
node.parentId = parentId;
|
||||
} else if (parentId === null) {
|
||||
node.parentId = _SYNTH_ROOT_ID('own');
|
||||
}
|
||||
const res = await api.post('/api/files/folders', { name, parentId });
|
||||
const node = _mapFolderToNode(res.data, 'own', [], includeFiles);
|
||||
typeMap.set(node.id, 'folder');
|
||||
return node;
|
||||
},
|
||||
|
||||
|
|
@ -239,9 +225,8 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
|||
: targetParentId;
|
||||
await Promise.all(
|
||||
ids.map((id) => {
|
||||
if (id.startsWith('__filesRoot:')) return Promise.resolve();
|
||||
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: apiTarget });
|
||||
return api.post(`/api/files/folders/${id}/move`, { targetParentId: apiTarget });
|
||||
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
|
||||
return api.post(`/api/files/folders/${id}/move`, { parentId: targetParentId });
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -47,6 +47,16 @@ export interface TreeNode<T = any> {
|
|||
* pending spinner on click. Tree has no knowledge of action semantics. */
|
||||
extraActions?: NodeAction[];
|
||||
data?: T;
|
||||
/**
|
||||
* From bulk `/folders/tree` response: another folder references this folder as parent.
|
||||
* When false AND no lazy-file mode, omit expand affordance immediately.
|
||||
*/
|
||||
hasSubfoldersInApiTree?: boolean;
|
||||
/**
|
||||
* Folder tree mixes in files lazily (`includeFiles` in FolderFileProvider). When true but
|
||||
* no subfolders in API snapshot, expand may still reveal files → keep chevron until loaded.
|
||||
*/
|
||||
mayHaveLazyFileChildren?: boolean;
|
||||
}
|
||||
|
||||
export interface TreeBatchAction {
|
||||
|
|
@ -125,7 +135,12 @@ export interface FormGeneratorTreeProps<T = any> {
|
|||
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
|
||||
embedMaxHeight?: number;
|
||||
/**
|
||||
* Hides checkbox, size column, per-row emoji actions, drag-drop, and batch toolbar — saves space in pickers.
|
||||
* Hides checkbox, size column, per-row emoji actions, and batch toolbar — saves space in pickers.
|
||||
* Drag-drop defaults off when hidden; pass `enableDragDrop` to keep moving folders inside the mini tree.
|
||||
*/
|
||||
hideRowActionButtons?: boolean;
|
||||
/** When true, folders remain draggable despite `hideRowActionButtons`. */
|
||||
enableDragDrop?: boolean;
|
||||
/** Hides the titled section header (count, refresh, new folder) — for compact embedded pickers. */
|
||||
hideSectionHeader?: boolean;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue