diff --git a/frontend/src/__tests__/tags/TagPicker.validation.test.tsx b/frontend/src/__tests__/tags/TagPicker.validation.test.tsx new file mode 100644 index 0000000..c5e84df --- /dev/null +++ b/frontend/src/__tests__/tags/TagPicker.validation.test.tsx @@ -0,0 +1,66 @@ +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().mockResolvedValue([]), + assignTag: vi.fn().mockResolvedValue({}), + unassignTag: vi.fn().mockResolvedValue({}), + createTag: vi.fn().mockResolvedValue({ id: 't1', name: 'NewTag', color: '#3B82F6' }), +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }), +})); + +import { TagPicker } from '@/components/tags/TagPicker'; +import { createTag } from '@/api/tags'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('TagPicker validation (RHF + Zod)', () => { + it('shows validation error when tag name is empty', async () => { + render(); + // Wait for loading to finish and find create button + await waitFor(() => { + expect(screen.getByTestId('tag-picker')).toBeInTheDocument(); + }, { timeout: 3000 }); + // Click create button to show form (German: 'Neues Tag') + const createBtn = screen.getByText(/neues tag|create/i); + fireEvent.click(createBtn); + // Submit empty form + await waitFor(() => { + expect(screen.getByTestId('create-tag-form')).toBeInTheDocument(); + }); + const form = screen.getByTestId('create-tag-form').querySelector('form'); + expect(form).toBeTruthy(); + fireEvent.submit(form!); + await waitFor(() => { + const errors = screen.getAllByText(/erforderlich|required/i); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + it('calls createTag when form is valid', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('tag-picker')).toBeInTheDocument(); + }, { timeout: 3000 }); + const createBtn = screen.getByText(/neues tag|create/i); + fireEvent.click(createBtn); + await waitFor(() => { + expect(screen.getByTestId('create-tag-form')).toBeInTheDocument(); + }); + // Fill in tag name + const form = screen.getByTestId('create-tag-form').querySelector('form'); + const input = form!.querySelector('input'); + expect(input).toBeTruthy(); + fireEvent.change(input!, { target: { value: 'Important' } }); + fireEvent.submit(form!); + await waitFor(() => { + expect(createTag).toHaveBeenCalledWith(expect.objectContaining({ name: 'Important' })); + }, { timeout: 3000 }); + }); +}); diff --git a/frontend/src/components/tags/TagPicker.tsx b/frontend/src/components/tags/TagPicker.tsx index 85b67e4..9e75e62 100644 --- a/frontend/src/components/tags/TagPicker.tsx +++ b/frontend/src/components/tags/TagPicker.tsx @@ -6,6 +6,9 @@ import clsx from 'clsx'; 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 { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -35,10 +38,22 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned const [loading, setLoading] = useState(true); const [showCreateForm, setShowCreateForm] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [newTagName, setNewTagName] = useState(''); - const [newTagColor, setNewTagColor] = useState('#3B82F6'); const [submitting, setSubmitting] = useState(false); + // ── Tag create form (RHF + Zod) ── + const tagSchema = z.object({ + name: z.string().min(1, 'required'), + color: z.string().optional().default('#3B82F6'), + }); + type TagFormData = z.infer; + + const { register: registerTag, handleSubmit: handleSubmitTag, reset: resetTag, watch: watchTag, setValue: setTagValue, formState: { errors: tagErrors } } = useForm({ + resolver: zodResolver(tagSchema), + defaultValues: { name: '', color: '#3B82F6' }, + }); + + const newTagColor = watchTag('color'); + useEffect(() => { let cancelled = false; setLoading(true); @@ -95,14 +110,13 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned setSubmitting(false); }, [entityType, entityId, toast, t]); - const handleCreateTag = useCallback(async () => { - if (!newTagName.trim()) return; + const handleCreateTag = useCallback(async (data: TagFormData) => { setSubmitting(true); try { - const tag = await createTag({ name: newTagName.trim(), color: newTagColor }); + const tag = await createTag({ name: data.name.trim(), color: data.color }); setAllTags((prev) => [...prev, tag]); await handleAssign(tag.id); - setNewTagName(''); + resetTag({ name: '', color: '#3B82F6' }); setShowCreateForm(false); toast.success(t('tags.createSuccess')); } catch (err) { @@ -110,7 +124,7 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned toast.error(msg); } setSubmitting(false); - }, [newTagName, newTagColor, handleAssign, toast, t]); + }, [handleAssign, toast, t, resetTag]); const colorOptions = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#F97316', '#EC4899', '#6B7280']; @@ -195,10 +209,11 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned ) : (
+
setNewTagName(e.target.value)} + label={t('tags.tagName')} + {...registerTag('name')} + error={tagErrors.name?.message === 'required' ? t('validation.required') : undefined} placeholder={t('tags.tagName')} />
@@ -207,7 +222,8 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned {colorOptions.map((color) => (
- - + +
+ )}