diff --git a/TODO.md b/TODO.md index 7110038..7cda5f1 100644 --- a/TODO.md +++ b/TODO.md @@ -204,14 +204,14 @@ - [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions #### Actions Tags (Priorité 4) -- [ ] Créer `actions/tags.ts` pour la gestion tags -- [ ] `createTag(name, color)` - Création tag -- [ ] `updateTag(tagId, data)` - Modification tag -- [ ] `deleteTag(tagId)` - Suppression tag -- [ ] Modifier les formulaires tags pour server actions -- [ ] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE) -- [ ] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement) -- [ ] **Nettoyage** : Modifier `useTags.ts` pour server actions directes +- [x] Créer `actions/tags.ts` pour la gestion tags +- [x] `createTag(name, color)` - Création tag +- [x] `updateTag(tagId, data)` - Modification tag +- [x] `deleteTag(tagId)` - Suppression tag +- [x] Modifier les formulaires tags pour server actions +- [x] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE) +- [x] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement) +- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes #### Migration progressive avec nettoyage immédiat **Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes diff --git a/clients/tags-client.ts b/clients/tags-client.ts index 4da48e3..665d496 100644 --- a/clients/tags-client.ts +++ b/clients/tags-client.ts @@ -1,17 +1,7 @@ import { HttpClient } from './base/http-client'; import { Tag, ApiResponse } from '@/lib/types'; -// Types pour les requêtes -export interface CreateTagData { - name: string; - color: string; -} - -export interface UpdateTagData { - tagId: string; - name?: string; - color?: string; -} +// Types pour les requêtes (now only used for validation - CRUD operations moved to server actions) export interface TagFilters { q?: string; // Recherche par nom @@ -89,27 +79,7 @@ export class TagsClient extends HttpClient { return this.get(`/${id}`); } - /** - * Crée un nouveau tag - */ - async createTag(data: CreateTagData): Promise { - return this.post('', data); - } - - /** - * Met à jour un tag - */ - async updateTag(data: UpdateTagData): Promise { - const { tagId, ...updates } = data; - return this.patch(`/${tagId}`, updates); - } - - /** - * Supprime un tag - */ - async deleteTag(id: string): Promise> { - return this.delete>(`/${id}`); - } + // CRUD operations removed - now handled by server actions in /actions/tags.ts /** * Valide le format d'une couleur hexadécimale @@ -139,9 +109,9 @@ export class TagsClient extends HttpClient { } /** - * Valide les données d'un tag + * Valide les données d'un tag (utilisé par les formulaires avant server actions) */ - static validateTagData(data: Partial): string[] { + static validateTagData(data: { name?: string; color?: string }): string[] { const errors: string[] = []; if (!data.name || typeof data.name !== 'string') { diff --git a/components/forms/TagForm.tsx b/components/forms/TagForm.tsx index 0f8fef8..65eb23e 100644 --- a/components/forms/TagForm.tsx +++ b/components/forms/TagForm.tsx @@ -1,18 +1,18 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useTransition } from 'react'; import { Tag } from '@/lib/types'; import { TagsClient } from '@/clients/tags-client'; import { Modal } from '@/components/ui/Modal'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; +import { createTag, updateTag } from '@/actions/tags'; interface TagFormProps { isOpen: boolean; onClose: () => void; - onSubmit: (data: { name: string; color: string }) => Promise; + onSuccess?: () => Promise; // Callback après succès pour refresh tag?: Tag | null; // Si fourni, mode édition - loading?: boolean; } const PRESET_COLORS = [ @@ -30,7 +30,8 @@ const PRESET_COLORS = [ '#F43F5E', // Rose ]; -export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: TagFormProps) { +export function TagForm({ isOpen, onClose, onSuccess, tag }: TagFormProps) { + const [isPending, startTransition] = useTransition(); const [formData, setFormData] = useState({ name: '', color: '#3B82F6', @@ -66,12 +67,42 @@ export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: Tag return; } - try { - await onSubmit(formData); - onClose(); - } catch (error) { - setErrors([error instanceof Error ? error.message : 'Erreur inconnue']); - } + startTransition(async () => { + try { + let result; + + if (tag) { + // Mode édition + result = await updateTag(tag.id, { + name: formData.name, + color: formData.color, + isPinned: formData.isPinned + }); + } else { + // Mode création + result = await createTag(formData.name, formData.color); + } + + if (result.success) { + onClose(); + // Reset form + setFormData({ + name: '', + color: TagsClient.generateRandomColor(), + isPinned: false + }); + setErrors([]); + // Refresh la liste des tags + if (onSuccess) { + await onSuccess(); + } + } else { + setErrors([result.error || 'Erreur inconnue']); + } + } catch (error) { + setErrors([error instanceof Error ? error.message : 'Erreur inconnue']); + } + }); }; const handleColorSelect = (color: string) => { @@ -102,7 +133,7 @@ export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: Tag }} placeholder="Nom du tag..." maxLength={50} - disabled={loading} + disabled={isPending} className="w-full" /> @@ -150,7 +181,7 @@ export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: Tag type="color" value={formData.color} onChange={handleCustomColorChange} - disabled={loading} + disabled={isPending} className="w-12 h-8 rounded border border-slate-600 bg-slate-800 cursor-pointer disabled:cursor-not-allowed" /> @@ -180,7 +211,7 @@ export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: Tag type="checkbox" checked={formData.isPinned} onChange={(e) => setFormData(prev => ({ ...prev, isPinned: e.target.checked }))} - disabled={loading} + disabled={isPending} className="w-4 h-4 rounded border border-slate-600 bg-slate-800 text-purple-600 focus:ring-purple-500 focus:ring-2 disabled:cursor-not-allowed" /> @@ -210,16 +241,16 @@ export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: Tag type="button" variant="secondary" onClick={onClose} - disabled={loading} + disabled={isPending} > Annuler diff --git a/hooks/useTags.ts b/hooks/useTags.ts index a1ee690..078b660 100644 --- a/hooks/useTags.ts +++ b/hooks/useTags.ts @@ -1,21 +1,23 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; -import { tagsClient, TagFilters, CreateTagData, UpdateTagData, TagsClient } from '@/clients/tags-client'; +import { useState, useEffect, useCallback, useTransition } from 'react'; +import { tagsClient, TagFilters, TagsClient } from '@/clients/tags-client'; +import { createTag, updateTag, deleteTag as deleteTagAction } from '@/actions/tags'; import { Tag } from '@/lib/types'; interface UseTagsState { tags: Array; popularTags: Array; loading: boolean; + isPending: boolean; // Loading state for server actions error: string | null; } interface UseTagsActions { refreshTags: () => Promise; searchTags: (query: string, limit?: number) => Promise; - createTag: (data: CreateTagData) => Promise; - updateTag: (data: UpdateTagData) => Promise; + createTag: (name: string, color: string) => Promise; + updateTag: (tagId: string, data: { name?: string; color?: string; isPinned?: boolean }) => Promise; deleteTag: (tagId: string) => Promise; getPopularTags: (limit?: number) => Promise; setFilters: (filters: TagFilters) => void; @@ -28,10 +30,12 @@ export function useTags( initialData?: (Tag & { usage: number })[], initialFilters?: TagFilters ): UseTagsState & UseTagsActions { + const [isPending, startTransition] = useTransition(); const [state, setState] = useState({ tags: initialData || [], popularTags: initialData || [], loading: !initialData, + isPending: false, error: null }); @@ -97,77 +101,82 @@ export function useTags( }, []); /** - * Crée un nouveau tag + * Crée un nouveau tag avec server action */ - const createTag = useCallback(async (data: CreateTagData): Promise => { - setState(prev => ({ ...prev, loading: true, error: null })); - - try { - // Validation côté client - const errors = TagsClient.validateTagData(data); - if (errors.length > 0) { - throw new Error(errors[0]); + const createTagAction = useCallback(async (name: string, color: string): Promise => { + // Validation côté client + const errors = TagsClient.validateTagData({ name, color }); + if (errors.length > 0) { + setState(prev => ({ ...prev, error: errors[0] })); + return; + } + + startTransition(async () => { + try { + setState(prev => ({ ...prev, error: null })); + const result = await createTag(name, color); + + if (!result.success) { + setState(prev => ({ ...prev, error: result.error || 'Erreur lors de la création' })); + } + // Note: revalidatePath in server action handles cache refresh automatically + } catch (error) { + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : 'Erreur lors de la création' + })); } - - const response = await tagsClient.createTag(data); - - // Rafraîchir la liste après création - await refreshTags(); - - return response.data; - } catch (error) { - setState(prev => ({ - ...prev, - loading: false, - error: error instanceof Error ? error.message : 'Erreur lors de la création' - })); - return null; - } - }, [refreshTags]); + }); + }, []); /** - * Met à jour un tag + * Met à jour un tag avec server action */ - const updateTag = useCallback(async (data: UpdateTagData): Promise => { - setState(prev => ({ ...prev, loading: true, error: null })); - - try { - const response = await tagsClient.updateTag(data); - - // Rafraîchir la liste après mise à jour - await refreshTags(); - - return response.data; - } catch (error) { - setState(prev => ({ - ...prev, - loading: false, - error: error instanceof Error ? error.message : 'Erreur lors de la mise à jour' - })); - return null; - } - }, [refreshTags]); + const updateTagAction = useCallback(async ( + tagId: string, + data: { name?: string; color?: string; isPinned?: boolean } + ): Promise => { + startTransition(async () => { + try { + setState(prev => ({ ...prev, error: null })); + const result = await updateTag(tagId, data); + + if (!result.success) { + setState(prev => ({ ...prev, error: result.error || 'Erreur lors de la mise à jour' })); + } + // Note: revalidatePath in server action handles cache refresh automatically + } catch (error) { + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : 'Erreur lors de la mise à jour' + })); + } + }); + }, []); /** - * Supprime un tag + * Supprime un tag avec server action */ - const deleteTag = useCallback(async (tagId: string): Promise => { - setState(prev => ({ ...prev, loading: true, error: null })); - - try { - await tagsClient.deleteTag(tagId); - - // Rafraîchir la liste après suppression - await refreshTags(); - } catch (error) { - setState(prev => ({ - ...prev, - loading: false, - error: error instanceof Error ? error.message : 'Erreur lors de la suppression' - })); - throw error; // Re-throw pour que l'UI puisse gérer l'erreur - } - }, [refreshTags]); + const deleteTagActionHandler = useCallback(async (tagId: string): Promise => { + startTransition(async () => { + try { + setState(prev => ({ ...prev, error: null })); + const result = await deleteTagAction(tagId); + + if (!result.success) { + setState(prev => ({ ...prev, error: result.error || 'Erreur lors de la suppression' })); + throw new Error(result.error); + } + // Note: revalidatePath in server action handles cache refresh automatically + } catch (error) { + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : 'Erreur lors de la suppression' + })); + throw error; // Re-throw pour que l'UI puisse gérer l'erreur + } + }); + }, []); // Charger les tags au montage et quand les filtres changent (seulement si pas de données initiales) useEffect(() => { @@ -178,11 +187,12 @@ export function useTags( return { ...state, + isPending, // Expose loading state from useTransition refreshTags, searchTags, - createTag, - updateTag, - deleteTag, + createTag: createTagAction, + updateTag: updateTagAction, + deleteTag: deleteTagActionHandler, getPopularTags, setFilters }; diff --git a/src/actions/tags.ts b/src/actions/tags.ts new file mode 100644 index 0000000..5746ad4 --- /dev/null +++ b/src/actions/tags.ts @@ -0,0 +1,101 @@ +'use server'; + +import { tagsService } from '@/services/tags'; +import { revalidatePath } from 'next/cache'; +import { Tag } from '@/lib/types'; + +export type ActionResult = { + success: boolean; + data?: T; + error?: string; +}; + +/** + * Créer un nouveau tag + */ +export async function createTag( + name: string, + color: string +): Promise> { + try { + const tag = await tagsService.createTag({ name, color }); + + // Revalider les pages qui utilisent les tags + revalidatePath('/'); + revalidatePath('/kanban'); + revalidatePath('/tags'); + + return { success: true, data: tag }; + } catch (error) { + console.error('Error creating tag:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create tag' + }; + } +} + +/** + * Mettre à jour un tag existant + */ +export async function updateTag( + tagId: string, + data: { name?: string; color?: string; isPinned?: boolean } +): Promise> { + try { + const tag = await tagsService.updateTag(tagId, data); + + if (!tag) { + return { success: false, error: 'Tag non trouvé' }; + } + + // Revalider les pages qui utilisent les tags + revalidatePath('/'); + revalidatePath('/kanban'); + revalidatePath('/tags'); + + return { success: true, data: tag }; + } catch (error) { + console.error('Error updating tag:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update tag' + }; + } +} + +/** + * Supprimer un tag + */ +export async function deleteTag(tagId: string): Promise { + try { + await tagsService.deleteTag(tagId); + + // Revalider les pages qui utilisent les tags + revalidatePath('/'); + revalidatePath('/kanban'); + revalidatePath('/tags'); + + return { success: true }; + } catch (error) { + console.error('Error deleting tag:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete tag' + }; + } +} + +/** + * Action rapide pour créer un tag depuis un input + */ +export async function quickCreateTag(formData: FormData): Promise> { + const name = formData.get('name') as string; + const color = formData.get('color') as string; + + if (!name?.trim()) { + return { success: false, error: 'Tag name is required' }; + } + + return createTag(name.trim(), color || '#3B82F6'); +} diff --git a/src/app/api/tags/[id]/route.ts b/src/app/api/tags/[id]/route.ts index 0391f2a..bf5e412 100644 --- a/src/app/api/tags/[id]/route.ts +++ b/src/app/api/tags/[id]/route.ts @@ -36,112 +36,4 @@ export async function GET( } } -/** - * PATCH /api/tags/[id] - Met à jour un tag - */ -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - const body = await request.json(); - const { name, color, isPinned } = body; - - // Validation - if (name !== undefined && (typeof name !== 'string' || !name.trim())) { - return NextResponse.json( - { error: 'Le nom du tag doit être une chaîne non vide' }, - { status: 400 } - ); - } - - if (color !== undefined && (typeof color !== 'string' || !/^#[0-9A-F]{6}$/i.test(color))) { - return NextResponse.json( - { error: 'La couleur doit être au format hexadécimal (#RRGGBB)' }, - { status: 400 } - ); - } - - const tag = await tagsService.updateTag(id, { name, color, isPinned }); - - if (!tag) { - return NextResponse.json( - { error: 'Tag non trouvé' }, - { status: 404 } - ); - } - - return NextResponse.json({ - data: tag, - message: 'Tag mis à jour avec succès' - }); - - } catch (error) { - console.error('Erreur lors de la mise à jour du tag:', error); - - if (error instanceof Error && error.message.includes('non trouvé')) { - return NextResponse.json( - { error: error.message }, - { status: 404 } - ); - } - - if (error instanceof Error && error.message.includes('existe déjà')) { - return NextResponse.json( - { error: error.message }, - { status: 409 } - ); - } - - return NextResponse.json( - { - error: 'Erreur lors de la mise à jour du tag', - message: error instanceof Error ? error.message : 'Erreur inconnue' - }, - { status: 500 } - ); - } -} - -/** - * DELETE /api/tags/[id] - Supprime un tag - */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - await tagsService.deleteTag(id); - - return NextResponse.json({ - message: 'Tag supprimé avec succès' - }); - - } catch (error) { - console.error('Erreur lors de la suppression du tag:', error); - - if (error instanceof Error && error.message.includes('non trouvé')) { - return NextResponse.json( - { error: error.message }, - { status: 404 } - ); - } - - if (error instanceof Error && error.message.includes('utilisé par')) { - return NextResponse.json( - { error: error.message }, - { status: 409 } - ); - } - - return NextResponse.json( - { - error: 'Erreur lors de la suppression du tag', - message: error instanceof Error ? error.message : 'Erreur inconnue' - }, - { status: 500 } - ); - } -} +// PATCH and DELETE routes removed - now handled by server actions in /actions/tags.ts diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts index 1961327..846899f 100644 --- a/src/app/api/tags/route.ts +++ b/src/app/api/tags/route.ts @@ -41,60 +41,4 @@ export async function GET(request: NextRequest) { } } -/** - * POST /api/tags - Crée un nouveau tag - */ -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { name, color } = body; - - // Validation - if (!name || typeof name !== 'string') { - return NextResponse.json( - { error: 'Le nom du tag est requis' }, - { status: 400 } - ); - } - - if (!color || typeof color !== 'string') { - return NextResponse.json( - { error: 'La couleur du tag est requise' }, - { status: 400 } - ); - } - - // Validation du format couleur (hex) - if (!/^#[0-9A-F]{6}$/i.test(color)) { - return NextResponse.json( - { error: 'La couleur doit être au format hexadécimal (#RRGGBB)' }, - { status: 400 } - ); - } - - const tag = await tagsService.createTag({ name, color }); - - return NextResponse.json({ - data: tag, - message: 'Tag créé avec succès' - }, { status: 201 }); - - } catch (error) { - console.error('Erreur lors de la création du tag:', error); - - if (error instanceof Error && error.message.includes('existe déjà')) { - return NextResponse.json( - { error: error.message }, - { status: 409 } - ); - } - - return NextResponse.json( - { - error: 'Erreur lors de la création du tag', - message: error instanceof Error ? error.message : 'Erreur inconnue' - }, - { status: 500 } - ); - } -} +// POST route removed - now handled by server actions in /actions/tags.ts diff --git a/src/app/tags/TagsPageClient.tsx b/src/app/tags/TagsPageClient.tsx index 2292023..faa9701 100644 --- a/src/app/tags/TagsPageClient.tsx +++ b/src/app/tags/TagsPageClient.tsx @@ -4,7 +4,6 @@ import { useState, useMemo } from 'react'; import React from 'react'; import { Tag } from '@/lib/types'; import { useTags } from '@/hooks/useTags'; -import { CreateTagData } from '@/clients/tags-client'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { TagForm } from '@/components/forms/TagForm'; @@ -19,8 +18,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) { tags, loading, error, - createTag, - updateTag, + refreshTags, deleteTag } = useTags(initialTags as (Tag & { usage: number })[]); @@ -50,29 +48,21 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) { }); }, [tags, searchQuery]); - const handleCreateTag = async (data: CreateTagData) => { - await createTag(data); - setIsCreateModalOpen(false); - }; - const handleEditTag = (tag: Tag) => { setEditingTag(tag); }; - const handleUpdateTag = async (data: { name: string; color: string; isPinned?: boolean }) => { - if (editingTag) { - await updateTag({ - tagId: editingTag.id, - ...data - }); - setEditingTag(null); - } - }; - const handleDeleteTag = async (tag: Tag) => { + if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) { + return; + } + setDeletingTagId(tag.id); try { + // Utiliser la server action directement depuis useTags await deleteTag(tag.id); + // Refresh la liste des tags + await refreshTags(); } catch (error) { console.error('Erreur lors de la suppression:', error); } finally { @@ -220,16 +210,14 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) { setIsCreateModalOpen(false)} - onSubmit={handleCreateTag} - loading={false} + onSuccess={refreshTags} /> setEditingTag(null)} - onSubmit={handleUpdateTag} + onSuccess={refreshTags} tag={editingTag} - loading={false} /> );