fix: readded new folder button to folder tree component
This commit is contained in:
parent
930a34662d
commit
25b56f585e
6 changed files with 186 additions and 13 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,6 +370,7 @@ export const FilesPage: React.FC = () => {
|
|||
ownership="own"
|
||||
title={t('Eigene')}
|
||||
showFilter={true}
|
||||
allowCreateFolder={canCreate}
|
||||
onNodeClick={_handleTreeNodeClick}
|
||||
onRefresh={() => _tableRefetch()}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue