fix: readded new folder button to folder tree component

This commit is contained in:
Ida 2026-05-06 08:19:37 +02:00
parent 930a34662d
commit 25b56f585e
6 changed files with 186 additions and 13 deletions

View file

@ -3,7 +3,9 @@ import {
FaChevronRight,
FaUnlink,
FaSyncAlt,
FaFolderPlus,
} from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
import type {
TreeNode,
TreeNodeProvider,
@ -81,6 +83,15 @@ function _flatten<T>(
return result;
}
function _resolveNewFolderParentId<T>(selectedIds: Set<string>, nodes: TreeNode<T>[]): string | null {
if (selectedIds.size !== 1) return null;
const id = [...selectedIds][0];
const node = nodes.find((n) => n.id === id);
if (!node) return null;
if (node.type === 'folder') return node.id;
return node.parentId ?? null;
}
function _collectDescendantIds<T>(nodeId: string, nodes: TreeNode<T>[]): string[] {
const childMap = _buildChildMap(nodes);
const result: string[] = [];
@ -390,8 +401,10 @@ export function FormGeneratorTree<T = any>({
onSelectionChange,
onRefresh,
onSendToChat,
allowCreateFolder = true,
className,
}: FormGeneratorTreeProps<T>) {
const { prompt, PromptDialog } = usePrompt();
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@ -577,6 +590,32 @@ export function FormGeneratorTree<T = any>({
onRefresh?.();
}, [_loadRoot, _updateSelection, onRefresh]);
const _handleNewFolder = useCallback(async () => {
if (ownership !== 'own' || !provider.createChild || !allowCreateFolder) return;
const parentId = _resolveNewFolderParentId(selectedIds, nodes);
if (provider.canCreate && !provider.canCreate(parentId)) return;
const name = await prompt('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
const trimmed = name?.trim();
if (!trimmed) return;
try {
const newNode = await provider.createChild(parentId, trimmed);
setNodes((prev) => [...prev, newNode]);
if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId));
}
} catch {
await _handleRefresh();
}
}, [
ownership,
provider,
allowCreateFolder,
selectedIds,
nodes,
prompt,
_handleRefresh,
]);
const _handleDelete = useCallback(
async (id: string) => {
const node = nodes.find((n) => n.id === id);
@ -801,6 +840,13 @@ export function FormGeneratorTree<T = any>({
const totalNodeCount = nodes.filter((n) => n.parentId === null).length;
const showNewFolderButton =
Boolean(title) &&
ownership === 'own' &&
allowCreateFolder &&
Boolean(provider.createChild) &&
(provider.canCreate?.(_resolveNewFolderParentId(selectedIds, nodes)) ?? true);
const wrapperClasses = [
styles.formGeneratorTree,
compact && styles.compactMode,
@ -825,6 +871,20 @@ export function FormGeneratorTree<T = any>({
)}
<span className={styles.sectionTitle}>{title}</span>
<span className={styles.sectionCount}>{totalNodeCount}</span>
{showNewFolderButton && (
<button
className={styles.refreshBtn}
onClick={(e) => {
e.stopPropagation();
_handleNewFolder();
}}
title="Neuer Ordner"
type="button"
tabIndex={-1}
>
<FaFolderPlus />
</button>
)}
<button
className={styles.refreshBtn}
onClick={(e) => {
@ -832,6 +892,7 @@ export function FormGeneratorTree<T = any>({
_handleRefresh();
}}
title="Aktualisieren"
type="button"
tabIndex={-1}
>
<FaSyncAlt />
@ -941,6 +1002,7 @@ export function FormGeneratorTree<T = any>({
</div>
</div>
)}
<PromptDialog />
</div>
);
}

View file

@ -2,11 +2,21 @@
// All rights reserved.
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormGeneratorTree } from '../FormGeneratorTree';
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
const { mockPrompt } = vi.hoisted(() => ({
mockPrompt: vi.fn(() => Promise.resolve('NeuOrdner')),
}));
vi.mock('../../../../hooks/usePrompt', () => ({
usePrompt: () => ({
prompt: mockPrompt,
PromptDialog: () => null,
}),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
@ -90,6 +100,11 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
describe('FormGeneratorTree', () => {
describe('Rendering', () => {
beforeEach(() => {
mockPrompt.mockClear();
mockPrompt.mockResolvedValue('NeuOrdner');
});
it('renders tree with title and node count', async () => {
const provider = _createMockProvider([_ownFolder]);
render(
@ -174,6 +189,85 @@ describe('FormGeneratorTree', () => {
});
});
// ---------------------------------------------------------------------------
// New folder
// ---------------------------------------------------------------------------
describe('New folder', () => {
beforeEach(() => {
mockPrompt.mockClear();
mockPrompt.mockResolvedValue('NeuOrdner');
});
it('shows header button when titled own tree has createChild', async () => {
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" title="Documents" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.getByTitle('Neuer Ordner')).toBeInTheDocument();
});
it('does not show new folder for shared tree', async () => {
const provider = _createMockProvider([_sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" title="Shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
});
it('calls createChild at root when nothing selected', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByTitle('Neuer Ordner'));
await waitFor(() => {
expect(provider.createChild).toHaveBeenCalledWith(null, 'NeuOrdner');
});
});
it('calls createChild under selected folder', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
await user.click(screen.getByTitle('Neuer Ordner'));
await waitFor(() => {
expect(provider.createChild).toHaveBeenCalledWith('f1', 'NeuOrdner');
});
});
it('hides button when allowCreateFolder is false', async () => {
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
title="Docs"
allowCreateFolder={false}
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------------
@ -228,8 +322,8 @@ describe('FormGeneratorTree', () => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
await user.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
fireEvent.click(screen.getByRole('treeitem', { name: /My Folder/i }));
fireEvent.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
ctrlKey: true,
});
@ -238,7 +332,7 @@ describe('FormGeneratorTree', () => {
expect(lastCall.has('f2')).toBe(true);
});
it('click on selected folder cascades deselect of descendants (own)', async () => {
it('second click on folder with cascaded child selection keeps cascaded selection (own)', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
const provider = _createMockProvider([_ownFolder, _ownFile]);
@ -270,12 +364,11 @@ describe('FormGeneratorTree', () => {
expect(lastCall.has('f1')).toBe(true);
expect(lastCall.has('file1')).toBe(true);
// Click again to deselect
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('f1')).toBe(false);
expect(lastCall.has('file1')).toBe(false);
expect(lastCall.has('f1')).toBe(true);
expect(lastCall.has('file1')).toBe(true);
});
it('selection in shared tree does NOT cascade to children', async () => {
@ -455,6 +548,13 @@ describe('FormGeneratorTree', () => {
// ---------------------------------------------------------------------------
describe('Delete', () => {
beforeEach(() => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('delete button calls provider.deleteNodes', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
@ -465,7 +565,7 @@ describe('FormGeneratorTree', () => {
});
const row = screen.getByRole('treeitem', { name: /My Folder/i });
const deleteBtn = within(row).getByTitle('Delete');
const deleteBtn = within(row).getByTitle('Loeschen');
await user.click(deleteBtn);
await waitFor(() => {
@ -482,7 +582,7 @@ describe('FormGeneratorTree', () => {
});
const row = screen.getByRole('treeitem', { name: /Shared Folder/i });
expect(within(row).queryByTitle('Delete')).not.toBeInTheDocument();
expect(within(row).queryByTitle('Loeschen')).not.toBeInTheDocument();
});
});
@ -541,7 +641,7 @@ describe('FormGeneratorTree', () => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const neutralizeBtn = screen.getByTitle('Not neutralized');
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
await user.click(neutralizeBtn);
await waitFor(() => {
@ -562,7 +662,7 @@ describe('FormGeneratorTree', () => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
const neutralizeBtn = screen.getByTitle('Not neutralized');
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
await user.click(neutralizeBtn);
expect(provider.patchNeutralize).not.toHaveBeenCalled();

View file

@ -6,6 +6,12 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { FormGeneratorTree } from '../FormGeneratorTree';
import type { TreeNode, TreeNodeProvider } from '../types';
vi.mock('../../../../hooks/usePrompt', () => ({
usePrompt: () => ({
prompt: vi.fn(() => Promise.resolve('x')),
PromptDialog: () => null,
}),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

View file

@ -108,7 +108,7 @@ export function createFolderFileProvider(): TreeNodeProvider {
return nodes;
},
canCreate() {
canCreate(_parentId: string | null) {
return true;
},
@ -137,7 +137,9 @@ export function createFolderFileProvider(): TreeNodeProvider {
async createChild(parentId, name) {
const res = await api.post('/api/files/folders', { name, parentId });
return _mapFolderToNode(res.data, 'own');
const node = _mapFolderToNode(res.data, 'own');
typeMap.set(node.id, 'folder');
return node;
},
async renameNode(id, newName) {

View file

@ -60,5 +60,7 @@ export interface FormGeneratorTreeProps<T = any> {
onSelectionChange?: (selectedIds: Set<string>) => void;
onRefresh?: () => void;
onSendToChat?: (node: TreeNode<T>) => void;
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
allowCreateFolder?: boolean;
className?: string;
}

View file

@ -370,6 +370,7 @@ export const FilesPage: React.FC = () => {
ownership="own"
title={t('Eigene')}
showFilter={true}
allowCreateFolder={canCreate}
onNodeClick={_handleTreeNodeClick}
onRefresh={() => _tableRefetch()}
/>