feat: migrate tag management to server actions

- Completed migration of tag management to server actions by creating `actions/tags.ts` for CRUD operations.
- Removed obsolete API routes for tags (`/api/tags`, `/api/tags/[id]`) and updated related components to utilize new server actions.
- Updated `TagForm` and `useTags` hook to handle tag creation, updating, and deletion through server actions, improving performance and simplifying client-side logic.
- Cleaned up `tags-client.ts` by removing unused types and methods, focusing on validation only.
- Marked related TODO items as complete in `TODO.md`.
This commit is contained in:
Julien Froidefond
2025-09-18 21:00:28 +02:00
parent 8b98c467d0
commit b12dd4f8dc
8 changed files with 253 additions and 317 deletions

16
TODO.md
View File

@@ -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

View File

@@ -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<TagResponse>(`/${id}`);
}
/**
* Crée un nouveau tag
*/
async createTag(data: CreateTagData): Promise<TagResponse> {
return this.post<TagResponse>('', data);
}
/**
* Met à jour un tag
*/
async updateTag(data: UpdateTagData): Promise<TagResponse> {
const { tagId, ...updates } = data;
return this.patch<TagResponse>(`/${tagId}`, updates);
}
/**
* Supprime un tag
*/
async deleteTag(id: string): Promise<ApiResponse<void>> {
return this.delete<ApiResponse<void>>(`/${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<CreateTagData>): string[] {
static validateTagData(data: { name?: string; color?: string }): string[] {
const errors: string[] = [];
if (!data.name || typeof data.name !== 'string') {

View File

@@ -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<void>;
onSuccess?: () => Promise<void>; // 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"
/>
</div>
@@ -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"
/>
<Input
@@ -163,7 +194,7 @@ export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: Tag
}}
placeholder="#RRGGBB"
maxLength={7}
disabled={loading}
disabled={isPending}
className="w-24 text-xs font-mono"
/>
</div>
@@ -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"
/>
<span className="text-sm text-slate-300">
@@ -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
</Button>
<Button
type="submit"
variant="primary"
disabled={loading || !formData.name.trim()}
disabled={isPending || !formData.name.trim()}
>
{loading ? 'Enregistrement...' : (tag ? 'Mettre à jour' : 'Créer')}
{isPending ? 'Enregistrement...' : (tag ? 'Mettre à jour' : 'Créer')}
</Button>
</div>
</form>

View File

@@ -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<Tag & { usage: number }>;
popularTags: Array<Tag & { usage: number }>;
loading: boolean;
isPending: boolean; // Loading state for server actions
error: string | null;
}
interface UseTagsActions {
refreshTags: () => Promise<void>;
searchTags: (query: string, limit?: number) => Promise<Tag[]>;
createTag: (data: CreateTagData) => Promise<Tag | null>;
updateTag: (data: UpdateTagData) => Promise<Tag | null>;
createTag: (name: string, color: string) => Promise<void>;
updateTag: (tagId: string, data: { name?: string; color?: string; isPinned?: boolean }) => Promise<void>;
deleteTag: (tagId: string) => Promise<void>;
getPopularTags: (limit?: number) => Promise<void>;
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<UseTagsState>({
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<Tag | null> => {
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<void> => {
// 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<Tag | null> => {
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<void> => {
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<void> => {
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<void> => {
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
};

101
src/actions/tags.ts Normal file
View File

@@ -0,0 +1,101 @@
'use server';
import { tagsService } from '@/services/tags';
import { revalidatePath } from 'next/cache';
import { Tag } from '@/lib/types';
export type ActionResult<T = void> = {
success: boolean;
data?: T;
error?: string;
};
/**
* Créer un nouveau tag
*/
export async function createTag(
name: string,
color: string
): Promise<ActionResult<Tag>> {
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<ActionResult<Tag>> {
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<ActionResult> {
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<ActionResult<Tag>> {
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');
}

View File

@@ -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

View File

@@ -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

View File

@@ -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) {
<TagForm
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSubmit={handleCreateTag}
loading={false}
onSuccess={refreshTags}
/>
<TagForm
isOpen={!!editingTag}
onClose={() => setEditingTag(null)}
onSubmit={handleUpdateTag}
onSuccess={refreshTags}
tag={editingTag}
loading={false}
/>
</div>
);