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 { 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 && (
|
||||||
<>
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid var(--color-border, #cbd5e1)',
|
||||||
|
background: 'var(--table-header-bg, #f1f5f9)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: panelOpen ? 6 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPanelOpen((o) => !o)}
|
onClick={() => setPanelOpen((o) => !o)}
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
width: '100%',
|
gap: 8,
|
||||||
padding: '6px 10px',
|
minWidth: 0,
|
||||||
marginBottom: panelOpen ? 6 : 0,
|
padding: '8px 10px',
|
||||||
borderRadius: 6,
|
border: 'none',
|
||||||
border: '1px solid var(--color-border, #cbd5e1)',
|
background: 'transparent',
|
||||||
background: 'var(--table-header-bg, #f1f5f9)',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
color: 'var(--color-text, #334155)',
|
color: 'var(--color-text, #334155)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||||
{panelOpen ? t('Ordnerbaum ausblenden') : t('Ordnerbaum einblenden')}
|
{triggerLabel}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden style={{ marginLeft: 8, flexShrink: 0 }}>
|
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
|
||||||
{panelOpen ? '▾' : '▸'}
|
{panelOpen ? '▾' : '▸'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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
|
||||||
|
|
@ -70,44 +172,94 @@ export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, valu
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: 'var(--color-bg, #fff)',
|
background: 'var(--color-bg, #fff)',
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
borderBottom: '1px solid var(--color-border, #e2e8f0)',
|
||||||
|
background: 'var(--table-header-bg, #f8fafc)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onChange('')}
|
onClick={() => {
|
||||||
|
clearFolder();
|
||||||
|
setPanelOpen(false);
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange('');
|
clearFolder();
|
||||||
|
setPanelOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderBottom: '1px solid var(--color-border, #e2e8f0)',
|
display: 'flex',
|
||||||
background: rootSelected
|
alignItems: 'center',
|
||||||
? 'rgba(37, 99, 235, 0.12)'
|
minHeight: 36,
|
||||||
: 'var(--table-header-bg, #f8fafc)',
|
background: rootSelected ? 'rgba(37, 99, 235, 0.12)' : 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('Stamm — Meine Dateien')}
|
{t('Stamm — Meine Dateien')}
|
||||||
</div>
|
</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
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,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>[] = [];
|
||||||
|
|
@ -112,8 +113,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);
|
||||||
|
|
@ -181,6 +196,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>({
|
||||||
|
|
@ -215,6 +231,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);
|
||||||
|
|
@ -301,11 +318,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"
|
||||||
|
|
@ -485,6 +502,8 @@ export function FormGeneratorTree<T = any>({
|
||||||
className,
|
className,
|
||||||
embedMaxHeight,
|
embedMaxHeight,
|
||||||
hideRowActionButtons = false,
|
hideRowActionButtons = false,
|
||||||
|
hideSectionHeader = false,
|
||||||
|
enableDragDrop,
|
||||||
}: FormGeneratorTreeProps<T>) {
|
}: FormGeneratorTreeProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
@ -499,8 +518,8 @@ 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('');
|
||||||
/** Map of nodeId -> set of action keys currently pending (for spinner rendering). */
|
/** Folders we expanded and confirmed have no visible children → hide chevron like a real leaf */
|
||||||
const [pendingActions, setPendingActions] = useState<Map<string, Set<string>>>(new Map());
|
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);
|
||||||
/** Tracks node ids for which auto-expand has already fired (one-shot). */
|
/** Tracks node ids for which auto-expand has already fired (one-shot). */
|
||||||
|
|
@ -597,6 +616,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
autoExpandedRef.current.clear();
|
autoExpandedRef.current.clear();
|
||||||
setExpandedIds(new Set());
|
setExpandedIds(new Set());
|
||||||
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) {
|
||||||
|
|
@ -611,46 +631,10 @@ export function FormGeneratorTree<T = any>({
|
||||||
_loadRoot();
|
_loadRoot();
|
||||||
}, [_loadRoot]);
|
}, [_loadRoot]);
|
||||||
|
|
||||||
/** Auto-expand nodes with `defaultExpanded=true` from backend, one-shot per id.
|
const flatEntriesRaw = useMemo(
|
||||||
* Fetches children first, then sets expandedIds + merges atomically so the
|
() => _flatten(nodes, expandedIds, confirmedEmptyFolderIds),
|
||||||
* expanded arrow never appears without visible children. */
|
[nodes, expandedIds, confirmedEmptyFolderIds],
|
||||||
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 flatEntries = useMemo(() => {
|
const flatEntries = useMemo(() => {
|
||||||
const term = filterText.trim().toLowerCase();
|
const term = filterText.trim().toLowerCase();
|
||||||
|
|
@ -684,15 +668,21 @@ export function FormGeneratorTree<T = any>({
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const wasExpanded = expandedIds.has(id);
|
const wasExpanded = expandedIds.has(id);
|
||||||
|
|
||||||
if (wasExpanded) {
|
const node = nodes.find((n) => n.id === id);
|
||||||
// Collapse: remove all descendants from nodes state and expandedIds.
|
if (node && !wasExpanded) {
|
||||||
const descendantIds = new Set<string>();
|
const childMap = _buildChildMap(nodes);
|
||||||
const _collectDescendants = (parentId: string) => {
|
const existingChildren = childMap.get(id);
|
||||||
for (const n of nodes) {
|
if (!existingChildren || existingChildren.length === 0) {
|
||||||
if (n.parentId === parentId && !descendantIds.has(n.id)) {
|
const childNodes = await provider.loadChildren(id, ownership);
|
||||||
descendantIds.add(n.id);
|
if (childNodes.length > 0) {
|
||||||
_collectDescendants(n.id);
|
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);
|
_collectDescendants(id);
|
||||||
|
|
@ -828,6 +818,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
try {
|
try {
|
||||||
const newNode = await provider.createChild(parentId, trimmed);
|
const newNode = await provider.createChild(parentId, trimmed);
|
||||||
|
<<<<<<< HEAD
|
||||||
setNodes((prev) => _mergeNodes(prev, [newNode]));
|
setNodes((prev) => _mergeNodes(prev, [newNode]));
|
||||||
// The provider may have re-parented `newNode` (e.g. onto a synth-root)
|
// The provider may have re-parented `newNode` (e.g. onto a synth-root)
|
||||||
// when `parentId === null`; expand whichever parent the resulting node
|
// when `parentId === null`; expand whichever parent the resulting node
|
||||||
|
|
@ -835,6 +826,16 @@ export function FormGeneratorTree<T = any>({
|
||||||
const visibleParent = newNode.parentId ?? null;
|
const visibleParent = newNode.parentId ?? null;
|
||||||
if (visibleParent) {
|
if (visibleParent) {
|
||||||
setExpandedIds((prev) => new Set(prev).add(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 {
|
} catch {
|
||||||
await _handleRefresh();
|
await _handleRefresh();
|
||||||
|
|
@ -1095,6 +1096,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];
|
||||||
|
|
@ -1134,7 +1137,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}
|
||||||
|
|
@ -1287,6 +1290,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,13 +127,8 @@ 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) === apiParentId);
|
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
|
||||||
const folderNodes = childFolders.map((f) => _mapFolderToNode(f, ownership));
|
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership, allFolders, includeFiles)));
|
||||||
// Re-parent top-level folders onto the synthetic root.
|
|
||||||
if (apiParentId === null) {
|
|
||||||
for (const n of folderNodes) n.parentId = synthRootId;
|
|
||||||
}
|
|
||||||
nodes.push(...folderNodes);
|
|
||||||
|
|
||||||
if (includeFiles) {
|
if (includeFiles) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -198,22 +197,9 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async createChild(parentId, name) {
|
async createChild(parentId, name) {
|
||||||
// Creating a folder under "/" means a top-level folder; map back to null
|
const res = await api.post('/api/files/folders', { name, parentId });
|
||||||
// for the API. The FE-only synth-root id never travels to the backend.
|
const node = _mapFolderToNode(res.data, 'own', [], includeFiles);
|
||||||
const apiParentId = parentId && parentId.startsWith('__filesRoot:') ? null : parentId;
|
typeMap.set(node.id, 'folder');
|
||||||
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');
|
|
||||||
}
|
|
||||||
return node;
|
return node;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -239,9 +225,8 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
||||||
: targetParentId;
|
: targetParentId;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ids.map((id) => {
|
ids.map((id) => {
|
||||||
if (id.startsWith('__filesRoot:')) return Promise.resolve();
|
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
|
||||||
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: apiTarget });
|
return api.post(`/api/files/folders/${id}/move`, { parentId: targetParentId });
|
||||||
return api.post(`/api/files/folders/${id}/move`, { targetParentId: apiTarget });
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,16 @@ export interface TreeNode<T = any> {
|
||||||
* pending spinner on click. Tree has no knowledge of action semantics. */
|
* pending spinner on click. Tree has no knowledge of action semantics. */
|
||||||
extraActions?: NodeAction[];
|
extraActions?: NodeAction[];
|
||||||
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 {
|
||||||
|
|
@ -125,7 +135,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