finished file tree folder selection in file create node
This commit is contained in:
parent
47b3c1ab23
commit
0941b9e0ad
4 changed files with 266 additions and 55 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 { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
import { usePrompt } from '../../../../hooks/usePrompt';
|
||||||
|
import { getFolderTree, createFolder } from '../../../../api/fileApi';
|
||||||
import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree';
|
import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree';
|
||||||
import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
|
import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
|
||||||
import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree';
|
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 }) => {
|
export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, request }) => {
|
||||||
const { t } = useLanguage();
|
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 provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []);
|
||||||
|
|
||||||
const strVal = typeof value === 'string' ? value : '';
|
const strVal = typeof value === 'string' ? value : '';
|
||||||
const rootSelected = strVal === '';
|
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(
|
const handleNodeClick = useCallback(
|
||||||
(node: TreeNode) => {
|
(node: TreeNode) => {
|
||||||
if (node.type === 'folder') {
|
if (node.type === 'folder') {
|
||||||
|
setPickedName(node.name);
|
||||||
onChange(node.id);
|
onChange(node.id);
|
||||||
|
setPanelOpen(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange],
|
[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 (
|
return (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
<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 && (
|
{request && (
|
||||||
<>
|
<>
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
onClick={() => setPanelOpen((o) => !o)}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '6px 10px',
|
alignItems: 'stretch',
|
||||||
marginBottom: panelOpen ? 6 : 0,
|
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
border: '1px solid var(--color-border, #cbd5e1)',
|
border: '1px solid var(--color-border, #cbd5e1)',
|
||||||
background: 'var(--table-header-bg, #f1f5f9)',
|
background: 'var(--table-header-bg, #f1f5f9)',
|
||||||
cursor: 'pointer',
|
overflow: 'hidden',
|
||||||
fontSize: 12,
|
marginBottom: panelOpen ? 6 : 0,
|
||||||
textAlign: 'left',
|
|
||||||
color: 'var(--color-text, #334155)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<button
|
||||||
{panelOpen ? t('Ordnerbaum ausblenden') : t('Ordnerbaum einblenden')}
|
type="button"
|
||||||
</span>
|
onClick={() => setPanelOpen((o) => !o)}
|
||||||
<span aria-hidden style={{ marginLeft: 8, flexShrink: 0 }}>
|
style={{
|
||||||
{panelOpen ? '▾' : '▸'}
|
flex: 1,
|
||||||
</span>
|
display: 'flex',
|
||||||
</button>
|
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 && (
|
{panelOpen && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -72,42 +174,92 @@ export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, valu
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => onChange('')}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onChange('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
display: 'flex',
|
||||||
fontSize: 12,
|
alignItems: 'stretch',
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: '1px solid var(--color-border, #e2e8f0)',
|
borderBottom: '1px solid var(--color-border, #e2e8f0)',
|
||||||
background: rootSelected
|
background: 'var(--table-header-bg, #f8fafc)',
|
||||||
? 'rgba(37, 99, 235, 0.12)'
|
|
||||||
: '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>
|
</div>
|
||||||
<FormGeneratorTree
|
<FormGeneratorTree
|
||||||
|
key={`user-folder-tree-${treeRefreshKey}`}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
ownership="own"
|
ownership="own"
|
||||||
title={t('Ordner')}
|
|
||||||
compact
|
compact
|
||||||
allowCreateFolder
|
allowCreateFolder={false}
|
||||||
showFilter={false}
|
showFilter={false}
|
||||||
emptyMessage={t('Noch keine Ordner')}
|
emptyMessage={t('Noch keine Ordner')}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
embedMaxHeight={260}
|
embedMaxHeight={240}
|
||||||
hideRowActionButtons
|
hideRowActionButtons
|
||||||
|
hideSectionHeader
|
||||||
|
enableDragDrop
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PromptDialog />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
|
||||||
function _flatten<T>(
|
function _flatten<T>(
|
||||||
nodes: TreeNode<T>[],
|
nodes: TreeNode<T>[],
|
||||||
expandedIds: Set<string>,
|
expandedIds: Set<string>,
|
||||||
|
confirmedEmptyFolderIds: Set<string>,
|
||||||
): FlatEntry<T>[] {
|
): FlatEntry<T>[] {
|
||||||
const childMap = _buildChildMap(nodes);
|
const childMap = _buildChildMap(nodes);
|
||||||
const result: FlatEntry<T>[] = [];
|
const result: FlatEntry<T>[] = [];
|
||||||
|
|
@ -70,8 +71,22 @@ function _flatten<T>(
|
||||||
const children = childMap.get(parentKey);
|
const children = childMap.get(parentKey);
|
||||||
if (!children) return;
|
if (!children) return;
|
||||||
for (const node of children) {
|
for (const node of children) {
|
||||||
const nodeChildren = childMap.get(node.id);
|
const loadedChildren = childMap.get(node.id) ?? [];
|
||||||
const hasChildren = (nodeChildren && nodeChildren.length > 0) || node.type === 'folder';
|
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 });
|
result.push({ node, depth, hasChildren });
|
||||||
if (hasChildren && expandedIds.has(node.id)) {
|
if (hasChildren && expandedIds.has(node.id)) {
|
||||||
_walk(node.id, depth + 1);
|
_walk(node.id, depth + 1);
|
||||||
|
|
@ -134,6 +149,7 @@ interface TreeNodeRowProps<T = any> {
|
||||||
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;
|
hideRowActionButtons?: boolean;
|
||||||
|
dragDropEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
|
|
@ -163,6 +179,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
onDragLeave,
|
onDragLeave,
|
||||||
onDrop,
|
onDrop,
|
||||||
hideRowActionButtons = false,
|
hideRowActionButtons = false,
|
||||||
|
dragDropEnabled = true,
|
||||||
}: TreeNodeRowProps<T>) {
|
}: TreeNodeRowProps<T>) {
|
||||||
const { node, depth, hasChildren } = entry;
|
const { node, depth, hasChildren } = entry;
|
||||||
const renameRef = useRef<HTMLInputElement>(null);
|
const renameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -243,11 +260,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
className={rowClasses}
|
className={rowClasses}
|
||||||
onClick={_handleRowClick}
|
onClick={_handleRowClick}
|
||||||
onDoubleClick={_handleDoubleClick}
|
onDoubleClick={_handleDoubleClick}
|
||||||
draggable={!hideRowActionButtons}
|
draggable={dragDropEnabled}
|
||||||
onDragStart={hideRowActionButtons ? undefined : (e) => onDragStart(e, node)}
|
onDragStart={dragDropEnabled ? (e) => onDragStart(e, node) : undefined}
|
||||||
onDragOver={hideRowActionButtons ? undefined : (e) => onDragOver(e, node)}
|
onDragOver={dragDropEnabled ? (e) => onDragOver(e, node) : undefined}
|
||||||
onDragLeave={hideRowActionButtons ? undefined : onDragLeave}
|
onDragLeave={dragDropEnabled ? onDragLeave : undefined}
|
||||||
onDrop={hideRowActionButtons ? undefined : (e) => onDrop(e, node)}
|
onDrop={dragDropEnabled ? (e) => onDrop(e, node) : undefined}
|
||||||
data-node-id={node.id}
|
data-node-id={node.id}
|
||||||
title={node.name}
|
title={node.name}
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
|
|
@ -425,6 +442,8 @@ export function FormGeneratorTree<T = any>({
|
||||||
className,
|
className,
|
||||||
embedMaxHeight,
|
embedMaxHeight,
|
||||||
hideRowActionButtons = false,
|
hideRowActionButtons = false,
|
||||||
|
hideSectionHeader = false,
|
||||||
|
enableDragDrop,
|
||||||
}: FormGeneratorTreeProps<T>) {
|
}: FormGeneratorTreeProps<T>) {
|
||||||
const { prompt, PromptDialog } = usePrompt();
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
|
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
|
||||||
|
|
@ -437,12 +456,15 @@ export function FormGeneratorTree<T = any>({
|
||||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||||
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
|
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
|
||||||
const [filterText, setFilterText] = useState('');
|
const [filterText, setFilterText] = useState('');
|
||||||
|
/** 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 lastSelectedIdRef = useRef<string | null>(null);
|
||||||
const treeContentRef = useRef<HTMLDivElement>(null);
|
const treeContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const _loadRoot = useCallback(async () => {
|
const _loadRoot = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
setConfirmedEmptyFolderIds(new Set());
|
||||||
const rootNodes = await provider.loadChildren(null, ownership);
|
const rootNodes = await provider.loadChildren(null, ownership);
|
||||||
setNodes(rootNodes);
|
setNodes(rootNodes);
|
||||||
if (defaultCollapsed && rootNodes.length === 0) {
|
if (defaultCollapsed && rootNodes.length === 0) {
|
||||||
|
|
@ -457,7 +479,10 @@ export function FormGeneratorTree<T = any>({
|
||||||
_loadRoot();
|
_loadRoot();
|
||||||
}, [_loadRoot]);
|
}, [_loadRoot]);
|
||||||
|
|
||||||
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]);
|
const flatEntriesRaw = useMemo(
|
||||||
|
() => _flatten(nodes, expandedIds, confirmedEmptyFolderIds),
|
||||||
|
[nodes, expandedIds, confirmedEmptyFolderIds],
|
||||||
|
);
|
||||||
|
|
||||||
const flatEntries = useMemo(() => {
|
const flatEntries = useMemo(() => {
|
||||||
const term = filterText.trim().toLowerCase();
|
const term = filterText.trim().toLowerCase();
|
||||||
|
|
@ -506,6 +531,13 @@ export function FormGeneratorTree<T = any>({
|
||||||
const childNodes = await provider.loadChildren(id, ownership);
|
const childNodes = await provider.loadChildren(id, ownership);
|
||||||
if (childNodes.length > 0) {
|
if (childNodes.length > 0) {
|
||||||
setNodes((prev) => [...prev, ...childNodes]);
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -623,6 +655,11 @@ export function FormGeneratorTree<T = any>({
|
||||||
const newNode = await provider.createChild(parentId, trimmed);
|
const newNode = await provider.createChild(parentId, trimmed);
|
||||||
setNodes((prev) => [...prev, newNode]);
|
setNodes((prev) => [...prev, newNode]);
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
|
setConfirmedEmptyFolderIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(parentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setExpandedIds((prev) => new Set(prev).add(parentId));
|
setExpandedIds((prev) => new Set(prev).add(parentId));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -851,6 +888,8 @@ export function FormGeneratorTree<T = any>({
|
||||||
);
|
);
|
||||||
}, [provider, ownership]);
|
}, [provider, ownership]);
|
||||||
|
|
||||||
|
const dragDropEnabled = enableDragDrop ?? !hideRowActionButtons;
|
||||||
|
|
||||||
const _filteredIdsForAction = useCallback(
|
const _filteredIdsForAction = useCallback(
|
||||||
(action: TreeBatchAction): string[] => {
|
(action: TreeBatchAction): string[] => {
|
||||||
const ids = [...selectedIds];
|
const ids = [...selectedIds];
|
||||||
|
|
@ -890,7 +929,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{title && (
|
{title && !hideSectionHeader && (
|
||||||
<div
|
<div
|
||||||
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
|
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
|
||||||
onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined}
|
onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined}
|
||||||
|
|
@ -1030,6 +1069,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
onDragLeave={_handleDragLeave}
|
onDragLeave={_handleDragLeave}
|
||||||
onDrop={_handleDrop}
|
onDrop={_handleDrop}
|
||||||
hideRowActionButtons={hideRowActionButtons}
|
hideRowActionButtons={hideRowActionButtons}
|
||||||
|
dragDropEnabled={dragDropEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ interface FileData {
|
||||||
sysCreatedBy?: string;
|
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 {
|
return {
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
|
|
@ -34,6 +36,8 @@ function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode {
|
||||||
neutralize: folder.neutralize,
|
neutralize: folder.neutralize,
|
||||||
contextOrphan: folder.contextOrphan,
|
contextOrphan: folder.contextOrphan,
|
||||||
icon: <FaFolder />,
|
icon: <FaFolder />,
|
||||||
|
hasSubfoldersInApiTree,
|
||||||
|
mayHaveLazyFileChildren,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +81,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
||||||
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
|
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
|
||||||
const allFolders: FolderData[] = foldersRes.data ?? [];
|
const allFolders: FolderData[] = foldersRes.data ?? [];
|
||||||
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
|
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
|
||||||
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership)));
|
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership, allFolders, includeFiles)));
|
||||||
|
|
||||||
if (includeFiles) {
|
if (includeFiles) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -140,7 +144,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
||||||
|
|
||||||
async createChild(parentId, name) {
|
async createChild(parentId, name) {
|
||||||
const res = await api.post('/api/files/folders', { name, parentId });
|
const res = await api.post('/api/files/folders', { name, parentId });
|
||||||
const node = _mapFolderToNode(res.data, 'own');
|
const node = _mapFolderToNode(res.data, 'own', [], includeFiles);
|
||||||
typeMap.set(node.id, 'folder');
|
typeMap.set(node.id, 'folder');
|
||||||
return node;
|
return node;
|
||||||
},
|
},
|
||||||
|
|
@ -164,7 +168,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ids.map((id) => {
|
ids.map((id) => {
|
||||||
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
|
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
|
||||||
return api.post(`/api/files/folders/${id}/move`, { targetParentId });
|
return api.post(`/api/files/folders/${id}/move`, { parentId: targetParentId });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ export interface TreeNode<T = any> {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
data?: T;
|
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 {
|
export interface TreeBatchAction {
|
||||||
|
|
@ -66,7 +76,12 @@ export interface FormGeneratorTreeProps<T = any> {
|
||||||
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
|
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
|
||||||
embedMaxHeight?: number;
|
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;
|
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