T08a: Frontend DMS + Tags + Permissions UI — 33 tests, tsc clean, vite build pass
- DMS file browser: folder tree + file grid + upload dropzone + search + preview modal - DMS share dialog: user/group share + public share links with password+expiry - DMS bulk actions: bulk move + bulk delete with confirm dialogs - DMS trash view: deleted files list with restore button - Tags: TagPicker on company/contact detail pages (new tabs tab) - Tags: TagCloud + BulkTagDialog for bulk tag assignment - Permissions: share link creation, permission display, copy-link button - API clients: dms.ts, tags.ts, permissions.ts - Routes: /dms, /dms/trash added to router - Sidebar: DMS nav link updated - i18n: de.json + en.json translations for DMS/Tags/Permissions - 33 new tests (5 test files), full regression 276/276 pass - tsc --noEmit: 0 errors, vite build: 252 modules
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('@/api/dms', () => ({
|
||||
fetchFolders: vi.fn().mockResolvedValue([
|
||||
{ id: 'f1', name: 'Vertraege', parent_id: null, created_by: 'u1', children: [] },
|
||||
{ id: 'f2', name: 'Rechnungen', parent_id: null, created_by: 'u1', children: [] },
|
||||
]),
|
||||
createFolder: vi.fn().mockResolvedValue({ id: 'f3', name: 'Neuer Ordner', parent_id: null, created_by: 'u1' }),
|
||||
deleteFile: vi.fn().mockResolvedValue(undefined),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [], total: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { DmsPage } from '@/pages/Dms';
|
||||
|
||||
function renderWithRouter() {
|
||||
return render(<MemoryRouter><DmsPage /></MemoryRouter>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('DmsPage', () => {
|
||||
it('renders the DMS page with title', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByTestId('dms-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders folder tree container', async () => {
|
||||
renderWithRouter();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('folder-tree-container')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders file grid container', async () => {
|
||||
renderWithRouter();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-grid-container')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders folder tree with folders after loading', async () => {
|
||||
renderWithRouter();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Vertraege')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rechnungen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows upload button and toggles upload dropzone', async () => {
|
||||
renderWithRouter();
|
||||
const uploadBtn = screen.getByText('Hochladen');
|
||||
expect(uploadBtn).toBeInTheDocument();
|
||||
fireEvent.click(uploadBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('upload-dropzone')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows new folder button and toggles form', async () => {
|
||||
renderWithRouter();
|
||||
const folderBtn = screen.getByText('Neuer Ordner');
|
||||
expect(folderBtn).toBeInTheDocument();
|
||||
fireEvent.click(folderBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('new-folder-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders trash link', async () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByText('Papierkorb')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search input', async () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByLabelText('Dateien durchsuchen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { UploadDropzone } from '@/components/dms/UploadDropzone';
|
||||
|
||||
const mockUploadFile = vi.fn();
|
||||
|
||||
vi.mock('@/api/dms', () => ({
|
||||
uploadFile: (...args: unknown[]) => mockUploadFile(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUploadFile.mockResolvedValue({ id: 'file1', name: 'test.pdf' });
|
||||
});
|
||||
|
||||
describe('UploadDropzone', () => {
|
||||
it('renders the dropzone area', () => {
|
||||
render(<UploadDropzone folderId={null} onUploaded={vi.fn()} />);
|
||||
expect(screen.getByTestId('upload-dropzone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders drag-and-drop instruction text', () => {
|
||||
render(<UploadDropzone folderId={null} onUploaded={vi.fn()} />);
|
||||
expect(screen.getByText('Dateien hierher ziehen oder klicken zum Auswaehlen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens file picker on click', () => {
|
||||
render(<UploadDropzone folderId={null} onUploaded={vi.fn()} />);
|
||||
const dropzone = screen.getByTestId('upload-dropzone');
|
||||
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
expect(input.multiple).toBe(true);
|
||||
});
|
||||
|
||||
it('uploads files when input changes', async () => {
|
||||
const onUploaded = vi.fn();
|
||||
render(<UploadDropzone folderId="f1" onUploaded={onUploaded} />);
|
||||
const input = screen.getByTestId('upload-dropzone').querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['test content'], 'report.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(file, 'f1');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows upload progress after file selection', async () => {
|
||||
render(<UploadDropzone folderId={null} onUploaded={vi.fn()} />);
|
||||
const input = screen.getByTestId('upload-dropzone').querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'doc.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('upload-progress-list')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ShareDialog } from '@/components/dms/ShareDialog';
|
||||
import type { DmsFile } from '@/api/dms';
|
||||
|
||||
const mockFile: DmsFile = {
|
||||
id: 'file1',
|
||||
folder_id: null,
|
||||
name: 'Vertrag.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size: 102400,
|
||||
storage_path: '/storage/file1',
|
||||
deleted_at: null,
|
||||
created_by: 'u1',
|
||||
};
|
||||
|
||||
vi.mock('@/api/permissions', () => ({
|
||||
fetchFilePermissions: vi.fn().mockResolvedValue([
|
||||
{ id: 'p1', file_id: 'file1', user_id: 'u2', group_id: null, permission: 'read', user_name: 'Max Mustermann', created_at: '2026-01-01T00:00:00Z' },
|
||||
]),
|
||||
grantPermission: vi.fn(),
|
||||
revokePermission: vi.fn().mockResolvedValue(undefined),
|
||||
createShareLink: vi.fn().mockResolvedValue({
|
||||
id: 'sl1', file_id: 'file1', token: 'abc123',
|
||||
url: 'https://example.com/share/abc123',
|
||||
password_protected: false, expires_at: null, created_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
revokeShareLink: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/dms', () => ({
|
||||
shareFile: vi.fn().mockResolvedValue({ id: 's1', file_id: 'file1', user_id: 'u3', permission: 'read' }),
|
||||
removeShare: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { fetchFilePermissions, createShareLink } from '@/api/permissions';
|
||||
import { shareFile } from '@/api/dms';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ShareDialog', () => {
|
||||
it('renders dialog when open with file', async () => {
|
||||
render(<ShareDialog open={true} file={mockFile} onClose={vi.fn()} onShared={vi.fn()} />);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when file is null', () => {
|
||||
render(<ShareDialog open={false} file={null} onClose={vi.fn()} onShared={vi.fn()} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders share add section with user/group select', async () => {
|
||||
render(<ShareDialog open={true} file={mockFile} onClose={vi.fn()} onShared={vi.fn()} />);
|
||||
expect(screen.getByTestId('share-add-section')).toBeInTheDocument();
|
||||
expect(screen.getByText('Benutzer oder Gruppe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders permissions list after loading', async () => {
|
||||
render(<ShareDialog open={true} file={mockFile} onClose={vi.fn()} onShared={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(fetchFilePermissions).toHaveBeenCalledWith('file1');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders public share link section', async () => {
|
||||
render(<ShareDialog open={true} file={mockFile} onClose={vi.fn()} onShared={vi.fn()} />);
|
||||
expect(screen.getByTestId('share-link-section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates share link when clicking create button', async () => {
|
||||
render(<ShareDialog open={true} file={mockFile} onClose={vi.fn()} onShared={vi.fn()} />);
|
||||
const createBtn = screen.getByRole('button', { name: 'Link erstellen' });
|
||||
fireEvent.click(createBtn);
|
||||
await waitFor(() => {
|
||||
expect(createShareLink).toHaveBeenCalledWith('file1', {});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('share-links-list')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shares file with user when add share is clicked', async () => {
|
||||
render(<ShareDialog open={true} file={mockFile} onClose={vi.fn()} onShared={vi.fn()} />);
|
||||
const userIdInput = screen.getByLabelText('Benutzer-ID');
|
||||
fireEvent.change(userIdInput, { target: { value: 'u5' } });
|
||||
const addBtn = screen.getByRole('button', { name: 'Freigabe hinzufuegen' });
|
||||
fireEvent.click(addBtn);
|
||||
await waitFor(() => {
|
||||
expect(shareFile).toHaveBeenCalledWith('file1', { user_id: 'u5', permission: 'read' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/api/tags', () => ({
|
||||
fetchTags: vi.fn(),
|
||||
bulkAssignTags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { BulkTagDialog } from '@/components/tags/BulkTagDialog';
|
||||
import { fetchTags, bulkAssignTags } from '@/api/tags';
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'VIP-Kunde', color: '#EF4444', created_by: 'u1', usage_count: 5 },
|
||||
{ id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1', usage_count: 12 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchTags).mockResolvedValue(mockTags);
|
||||
vi.mocked(bulkAssignTags).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('BulkTagDialog', () => {
|
||||
it('renders dialog when open', async () => {
|
||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1', 'c2']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows selected entity count', async () => {
|
||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1', 'c2', 'c3']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||
expect(screen.getByText('3 ausgewaehlte Eintraege')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads and displays available tags', async () => {
|
||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(fetchTags).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /VIP-Kunde/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /Lead/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles tag selection on click', async () => {
|
||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(fetchTags).toHaveBeenCalled();
|
||||
});
|
||||
const tagBtn = await screen.findByRole('button', { name: /VIP-Kunde/ });
|
||||
fireEvent.click(tagBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 Tags auswaehlen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls bulkAssignTags on assign button click', async () => {
|
||||
const onAssigned = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1', 'c2']} onClose={onClose} onAssigned={onAssigned} />);
|
||||
await waitFor(() => {
|
||||
expect(fetchTags).toHaveBeenCalled();
|
||||
});
|
||||
const tagBtn = await screen.findByRole('button', { name: /VIP-Kunde/ });
|
||||
fireEvent.click(tagBtn);
|
||||
const assignBtn = screen.getByRole('button', { name: 'Tags zuweisen' });
|
||||
fireEvent.click(assignBtn);
|
||||
await waitFor(() => {
|
||||
expect(bulkAssignTags).toHaveBeenCalledWith({
|
||||
tag_ids: ['t1'],
|
||||
entity_type: 'company',
|
||||
entity_ids: ['c1', 'c2'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<BulkTagDialog open={false} entityType="company" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { TagPicker } from '@/components/tags/TagPicker';
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'VIP-Kunde', color: '#EF4444', created_by: 'u1', usage_count: 5 },
|
||||
{ id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1', usage_count: 12 },
|
||||
{ id: 't3', name: 'Aktiv', color: '#10B981', created_by: 'u1', usage_count: 3 },
|
||||
];
|
||||
|
||||
const mockFetchTags = vi.fn();
|
||||
const mockAssignTag = vi.fn();
|
||||
const mockUnassignTag = vi.fn();
|
||||
const mockCreateTag = vi.fn();
|
||||
|
||||
vi.mock('@/api/tags', () => ({
|
||||
fetchTags: (...args: unknown[]) => mockFetchTags(...args),
|
||||
assignTag: (...args: unknown[]) => mockAssignTag(...args),
|
||||
unassignTag: (...args: unknown[]) => mockUnassignTag(...args),
|
||||
createTag: (...args: unknown[]) => mockCreateTag(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchTags.mockResolvedValue(mockTags);
|
||||
mockAssignTag.mockResolvedValue({ id: 'a1', tag_id: 't1', entity_type: 'company', entity_id: 'c1' });
|
||||
mockUnassignTag.mockResolvedValue(undefined);
|
||||
mockCreateTag.mockResolvedValue({ id: 't4', name: 'Neu', color: '#F59E0B', created_by: 'u1' });
|
||||
});
|
||||
|
||||
describe('TagPicker', () => {
|
||||
it('renders the tag picker container', async () => {
|
||||
render(<TagPicker entityType="company" entityId="c1" />);
|
||||
expect(screen.getByTestId('tag-picker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders assigned tags section', async () => {
|
||||
render(<TagPicker entityType="company" entityId="c1" />);
|
||||
expect(screen.getByText('Zugewiesene Tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no tags assigned message when empty', async () => {
|
||||
render(<TagPicker entityType="company" entityId="c1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Keine Tags zugewiesen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads and displays available tags', async () => {
|
||||
render(<TagPicker entityType="company" entityId="c1" />);
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTags).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('available-tags-list')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('VIP-Kunde')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lead')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('assigns a tag when clicking available tag', async () => {
|
||||
render(<TagPicker entityType="company" entityId="c1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('VIP-Kunde')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText('VIP-Kunde'));
|
||||
await waitFor(() => {
|
||||
expect(mockAssignTag).toHaveBeenCalledWith({ tag_id: 't1', entity_type: 'company', entity_id: 'c1' });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows create tag form when clicking create button', async () => {
|
||||
render(<TagPicker entityType="company" entityId="c1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Neues Tag')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText('Neues Tag'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-tag-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders search input for tags', async () => {
|
||||
render(<TagPicker entityType="company" entityId="c1" />);
|
||||
expect(screen.getByLabelText('Tag suchen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user