diff --git a/frontend/src/__tests__/dms/FolderCreate.validation.test.tsx b/frontend/src/__tests__/dms/FolderCreate.validation.test.tsx new file mode 100644 index 0000000..a81cd3e --- /dev/null +++ b/frontend/src/__tests__/dms/FolderCreate.validation.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('@/api/dms', () => ({ + shareFile: vi.fn().mockResolvedValue({}), + removeShare: vi.fn(), +})); + +vi.mock('@/api/permissions', () => ({ + fetchFilePermissions: vi.fn().mockResolvedValue([]), + grantPermission: vi.fn(), + revokePermission: vi.fn(), + createShareLink: vi.fn(), + revokeShareLink: vi.fn(), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }), +})); + +import { ShareDialog } from '@/components/dms/ShareDialog'; +import { shareFile } from '@/api/dms'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ShareDialog validation (RHF + Zod)', () => { + it('shows validation error when share ID is empty', async () => { + render( + + ); + // Find the submit button inside the add-share form + const addShareButtons = screen.getAllByRole('button', { name: /hinzufügen|add share/i }); + const submitBtn = addShareButtons.find((b) => b.getAttribute('type') === 'submit'); + expect(submitBtn).toBeTruthy(); + fireEvent.click(submitBtn!); + await waitFor(() => { + const errors = screen.getAllByText(/erforderlich|required/i); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + it('calls shareFile when form is valid', async () => { + render( + + ); + // Fill in share ID - find the input inside the share-add-section + const shareSection = screen.getByTestId('share-add-section'); + const input = shareSection.querySelector('input'); + expect(input).toBeTruthy(); + fireEvent.change(input!, { target: { value: 'user-123' } }); + fireEvent.submit(input!.closest('form')!); + await waitFor(() => { + expect(shareFile).toHaveBeenCalledWith('f1', expect.objectContaining({ user_id: 'user-123' })); + }, { timeout: 3000 }); + }); +}); diff --git a/frontend/src/components/dms/ShareDialog.tsx b/frontend/src/components/dms/ShareDialog.tsx index 580b85c..33f6c17 100644 --- a/frontend/src/components/dms/ShareDialog.tsx +++ b/frontend/src/components/dms/ShareDialog.tsx @@ -5,6 +5,9 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -45,12 +48,22 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps) const [loading, setLoading] = useState(false); const [shareType, setShareType] = useState<'user' | 'group'>('user'); - const [shareId, setShareId] = useState(''); const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read'); const [linkPassword, setLinkPassword] = useState(''); const [linkExpiry, setLinkExpiry] = useState(''); const [submitting, setSubmitting] = useState(false); + // ── Share form (RHF + Zod) ── + const shareSchema = z.object({ + shareId: z.string().min(1, 'required'), + }); + type ShareFormData = z.infer; + + const { register: registerShare, handleSubmit: handleSubmitShare, reset: resetShare, formState: { errors: shareErrors } } = useForm({ + resolver: zodResolver(shareSchema), + defaultValues: { shareId: '' }, + }); + const loadData = useCallback(async () => { if (!file) return; setLoading(true); @@ -69,18 +82,18 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps) } }, [open, file, loadData]); - const handleAddShare = useCallback(async () => { - if (!file || !shareId) return; + const handleAddShare = useCallback(async (data: ShareFormData) => { + if (!file) return; setSubmitting(true); try { const payload: { user_id?: string; group_id?: string; permission: 'read' | 'write' } = { permission: sharePermission, }; - if (shareType === 'user') payload.user_id = shareId; - else payload.group_id = shareId; + if (shareType === 'user') payload.user_id = data.shareId; + else payload.group_id = data.shareId; await shareFile(file.id, payload); toast.success(t('dms.shared')); - setShareId(''); + resetShare({ shareId: '' }); onShared(); loadData(); } catch (err) { @@ -88,7 +101,7 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps) toast.error(msg); } setSubmitting(false); - }, [file, shareId, shareType, sharePermission, toast, t, onShared, loadData]); + }, [file, shareType, sharePermission, toast, t, onShared, loadData, resetShare]); const handleRemovePermission = useCallback(async (userId: string) => { if (!file) return; @@ -155,6 +168,7 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps) {/* Add share section */} {t('dms.addShare')} + setShareId(e.target.value)} + {...registerShare('shareId')} + error={shareErrors.shareId?.message === 'required' ? t('validation.required') : undefined} placeholder={shareType === 'user' ? t('dms.userId') : t('dms.groupId')} /> {t('dms.addShare')} + {/* Current permissions */} diff --git a/frontend/src/pages/Dms.tsx b/frontend/src/pages/Dms.tsx index d41aeae..2c004e0 100644 --- a/frontend/src/pages/Dms.tsx +++ b/frontend/src/pages/Dms.tsx @@ -7,6 +7,9 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -69,7 +72,6 @@ export function DmsPage() { // Modal state const [showUpload, setShowUpload] = useState(false); const [showNewFolder, setShowNewFolder] = useState(false); - const [newFolderName, setNewFolderName] = useState(''); const [previewFile, setPreviewFile] = useState(null); const [shareFile, setShareFile] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -77,6 +79,17 @@ export function DmsPage() { const [showBulkMove, setShowBulkMove] = useState(false); const [bulkMoveTarget, setBulkMoveTarget] = useState(''); + // ── Folder create form (RHF + Zod) ── + const folderSchema = z.object({ + name: z.string().min(1, 'required'), + }); + type FolderFormData = z.infer; + + const { register: registerFolder, handleSubmit: handleSubmitFolder, reset: resetFolder, formState: { errors: folderErrors } } = useForm({ + resolver: zodResolver(folderSchema), + defaultValues: { name: '' }, + }); + // Mobile view state const [activeView, setActiveView] = useState<'tree' | 'files' | 'details'>('tree'); @@ -236,16 +249,15 @@ export function DmsPage() { }, [deleteTarget, selectedFile, toast, t]); // Handle create folder - const handleCreateFolder = useCallback(async () => { - if (!newFolderName.trim()) return; + const handleCreateFolder = useCallback(async (data: FolderFormData) => { setSubmittingFolder(true); try { const folder = await createFolder({ - name: newFolderName.trim(), + name: data.name.trim(), parent_id: selectedFolderId, }); setFolders((prev) => [...prev, folder]); - setNewFolderName(''); + resetFolder({ name: '' }); setShowNewFolder(false); toast.success(t('dms.createFolder')); } catch (err) { @@ -253,7 +265,7 @@ export function DmsPage() { toast.error(msg); } setSubmittingFolder(false); - }, [newFolderName, selectedFolderId, toast, t]); + }, [selectedFolderId, toast, t, resetFolder]); // Handle upload complete const handleUploadComplete = useCallback(() => { @@ -469,25 +481,22 @@ export function DmsPage() { {/* New folder form */} {showNewFolder && ( - + setNewFolderName(e.target.value)} + {...registerFolder('name')} + error={folderErrors.name?.message === 'required' ? t('validation.required') : undefined} placeholder={t('dms.folderName')} - onKeyDown={(e) => { - if (e.key === 'Enter') handleCreateFolder(); - }} /> - + {t('dms.createFolder')} - { setShowNewFolder(false); setNewFolderName(''); }}> + { setShowNewFolder(false); resetFolder({ name: '' }); }}> {t('dms.cancel')} - + )} {/* Upload dropzone */}