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,
|
FaChevronRight,
|
||||||
FaUnlink,
|
FaUnlink,
|
||||||
FaSyncAlt,
|
FaSyncAlt,
|
||||||
|
FaFolderPlus,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import type {
|
import type {
|
||||||
TreeNode,
|
TreeNode,
|
||||||
TreeNodeProvider,
|
TreeNodeProvider,
|
||||||
|
|
@ -81,6 +83,15 @@ function _flatten<T>(
|
||||||
return result;
|
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[] {
|
function _collectDescendantIds<T>(nodeId: string, nodes: TreeNode<T>[]): string[] {
|
||||||
const childMap = _buildChildMap(nodes);
|
const childMap = _buildChildMap(nodes);
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
|
|
@ -390,8 +401,10 @@ export function FormGeneratorTree<T = any>({
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onSendToChat,
|
onSendToChat,
|
||||||
|
allowCreateFolder = true,
|
||||||
className,
|
className,
|
||||||
}: FormGeneratorTreeProps<T>) {
|
}: FormGeneratorTreeProps<T>) {
|
||||||
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
|
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
@ -577,6 +590,32 @@ export function FormGeneratorTree<T = any>({
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
}, [_loadRoot, _updateSelection, 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(
|
const _handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const node = nodes.find((n) => n.id === id);
|
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 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 = [
|
const wrapperClasses = [
|
||||||
styles.formGeneratorTree,
|
styles.formGeneratorTree,
|
||||||
compact && styles.compactMode,
|
compact && styles.compactMode,
|
||||||
|
|
@ -825,6 +871,20 @@ export function FormGeneratorTree<T = any>({
|
||||||
)}
|
)}
|
||||||
<span className={styles.sectionTitle}>{title}</span>
|
<span className={styles.sectionTitle}>{title}</span>
|
||||||
<span className={styles.sectionCount}>{totalNodeCount}</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
|
<button
|
||||||
className={styles.refreshBtn}
|
className={styles.refreshBtn}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -832,6 +892,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
_handleRefresh();
|
_handleRefresh();
|
||||||
}}
|
}}
|
||||||
title="Aktualisieren"
|
title="Aktualisieren"
|
||||||
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<FaSyncAlt />
|
<FaSyncAlt />
|
||||||
|
|
@ -941,6 +1002,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,21 @@
|
||||||
// All rights reserved.
|
// All rights reserved.
|
||||||
|
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { FormGeneratorTree } from '../FormGeneratorTree';
|
import { FormGeneratorTree } from '../FormGeneratorTree';
|
||||||
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
|
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
|
// Fixtures
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -90,6 +100,11 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
||||||
|
|
||||||
describe('FormGeneratorTree', () => {
|
describe('FormGeneratorTree', () => {
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrompt.mockClear();
|
||||||
|
mockPrompt.mockResolvedValue('NeuOrdner');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders tree with title and node count', async () => {
|
it('renders tree with title and node count', async () => {
|
||||||
const provider = _createMockProvider([_ownFolder]);
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
render(
|
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
|
// Selection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -228,8 +322,8 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
fireEvent.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||||
await user.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
|
fireEvent.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -238,7 +332,7 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(lastCall.has('f2')).toBe(true);
|
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 user = userEvent.setup();
|
||||||
const onSelectionChange = vi.fn();
|
const onSelectionChange = vi.fn();
|
||||||
const provider = _createMockProvider([_ownFolder, _ownFile]);
|
const provider = _createMockProvider([_ownFolder, _ownFile]);
|
||||||
|
|
@ -270,12 +364,11 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(lastCall.has('f1')).toBe(true);
|
expect(lastCall.has('f1')).toBe(true);
|
||||||
expect(lastCall.has('file1')).toBe(true);
|
expect(lastCall.has('file1')).toBe(true);
|
||||||
|
|
||||||
// Click again to deselect
|
|
||||||
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||||
|
|
||||||
lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
|
lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
|
||||||
expect(lastCall.has('f1')).toBe(false);
|
expect(lastCall.has('f1')).toBe(true);
|
||||||
expect(lastCall.has('file1')).toBe(false);
|
expect(lastCall.has('file1')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selection in shared tree does NOT cascade to children', async () => {
|
it('selection in shared tree does NOT cascade to children', async () => {
|
||||||
|
|
@ -455,6 +548,13 @@ describe('FormGeneratorTree', () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('Delete', () => {
|
describe('Delete', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('delete button calls provider.deleteNodes', async () => {
|
it('delete button calls provider.deleteNodes', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const provider = _createMockProvider([_ownFolder]);
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
|
@ -465,7 +565,7 @@ describe('FormGeneratorTree', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = screen.getByRole('treeitem', { name: /My Folder/i });
|
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 user.click(deleteBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -482,7 +582,7 @@ describe('FormGeneratorTree', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = screen.getByRole('treeitem', { name: /Shared Folder/i });
|
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();
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const neutralizeBtn = screen.getByTitle('Not neutralized');
|
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||||
await user.click(neutralizeBtn);
|
await user.click(neutralizeBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -562,7 +662,7 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
|
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const neutralizeBtn = screen.getByTitle('Not neutralized');
|
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||||
await user.click(neutralizeBtn);
|
await user.click(neutralizeBtn);
|
||||||
|
|
||||||
expect(provider.patchNeutralize).not.toHaveBeenCalled();
|
expect(provider.patchNeutralize).not.toHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import { FormGeneratorTree } from '../FormGeneratorTree';
|
import { FormGeneratorTree } from '../FormGeneratorTree';
|
||||||
import type { TreeNode, TreeNodeProvider } from '../types';
|
import type { TreeNode, TreeNodeProvider } from '../types';
|
||||||
|
|
||||||
|
vi.mock('../../../../hooks/usePrompt', () => ({
|
||||||
|
usePrompt: () => ({
|
||||||
|
prompt: vi.fn(() => Promise.resolve('x')),
|
||||||
|
PromptDialog: () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
return nodes;
|
return nodes;
|
||||||
},
|
},
|
||||||
|
|
||||||
canCreate() {
|
canCreate(_parentId: string | null) {
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -137,7 +137,9 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
|
|
||||||
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 });
|
||||||
return _mapFolderToNode(res.data, 'own');
|
const node = _mapFolderToNode(res.data, 'own');
|
||||||
|
typeMap.set(node.id, 'folder');
|
||||||
|
return node;
|
||||||
},
|
},
|
||||||
|
|
||||||
async renameNode(id, newName) {
|
async renameNode(id, newName) {
|
||||||
|
|
|
||||||
|
|
@ -60,5 +60,7 @@ export interface FormGeneratorTreeProps<T = any> {
|
||||||
onSelectionChange?: (selectedIds: Set<string>) => void;
|
onSelectionChange?: (selectedIds: Set<string>) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onSendToChat?: (node: TreeNode<T>) => void;
|
onSendToChat?: (node: TreeNode<T>) => void;
|
||||||
|
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
|
||||||
|
allowCreateFolder?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,7 @@ export const FilesPage: React.FC = () => {
|
||||||
ownership="own"
|
ownership="own"
|
||||||
title={t('Eigene')}
|
title={t('Eigene')}
|
||||||
showFilter={true}
|
showFilter={true}
|
||||||
|
allowCreateFolder={canCreate}
|
||||||
onNodeClick={_handleTreeNodeClick}
|
onNodeClick={_handleTreeNodeClick}
|
||||||
onRefresh={() => _tableRefetch()}
|
onRefresh={() => _tableRefetch()}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue