From e2527ca88a8faed904161559e6c79daa550fced3 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Wed, 1 Oct 2025 21:11:50 +0200 Subject: [PATCH] feat: add primary tag functionality to tasks - Introduced `primaryTagId` to `Task` model and updated related components to support selecting a primary tag. - Enhanced `TaskCard`, `EditTaskForm`, and `TagInput` to handle primary tag selection and display. - Updated `TasksService` to manage primary tag data during task creation and updates. - Added `emoji-regex` dependency for improved emoji handling in task titles. --- package-lock.json | 15 ++- package.json | 1 + prisma/schema.prisma | 13 ++- src/actions/tasks.ts | 6 +- src/components/forms/EditTaskForm.tsx | 6 ++ src/components/forms/task/TaskTagsSection.tsx | 15 ++- src/components/kanban/TaskCard.tsx | 1 + src/components/ui/TagDisplay.tsx | 2 +- src/components/ui/TagInput.tsx | 91 +++++++++++++++---- src/components/ui/TaskCard.tsx | 33 +++++-- src/lib/types.ts | 2 + src/services/task-management/tasks.ts | 25 ++++- 12 files changed, 168 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 64feaef..9da1db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "bcryptjs": "^3.0.2", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "emoji-regex": "^10.5.0", "next": "15.5.3", "next-auth": "^4.24.11", "prisma": "^6.16.1", @@ -3807,10 +3808,9 @@ } }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "license": "MIT" }, "node_modules/empathic": { @@ -4336,6 +4336,13 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", diff --git a/package.json b/package.json index 5ee8f15..1d75b96 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "bcryptjs": "^3.0.2", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "emoji-regex": "^10.5.0", "next": "15.5.3", "next-auth": "^4.24.11", "prisma": "^6.16.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f9428f5..ace03fb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,6 +46,8 @@ model Task { tfsRepository String? tfsSourceBranch String? tfsTargetBranch String? + primaryTagId String? + primaryTag Tag? @relation("PrimaryTag", fields: [primaryTagId], references: [id]) dailyCheckboxes DailyCheckbox[] taskTags TaskTag[] @@ -54,11 +56,12 @@ model Task { } model Tag { - id String @id @default(cuid()) - name String @unique - color String @default("#6b7280") - isPinned Boolean @default(false) - taskTags TaskTag[] + id String @id @default(cuid()) + name String @unique + color String @default("#6b7280") + isPinned Boolean @default(false) + taskTags TaskTag[] + primaryTasks Task[] @relation("PrimaryTag") @@map("tags") } diff --git a/src/actions/tasks.ts b/src/actions/tasks.ts index 221c6cf..b4a9048 100644 --- a/src/actions/tasks.ts +++ b/src/actions/tasks.ts @@ -93,6 +93,7 @@ export async function updateTask(data: { status?: TaskStatus; priority?: TaskPriority; tags?: string[]; + primaryTagId?: string; dueDate?: Date; }): Promise { try { @@ -109,6 +110,7 @@ export async function updateTask(data: { if (data.status !== undefined) updateData.status = data.status; if (data.priority !== undefined) updateData.priority = data.priority; if (data.tags !== undefined) updateData.tags = data.tags; + if (data.primaryTagId !== undefined) updateData.primaryTagId = data.primaryTagId; if (data.dueDate !== undefined) updateData.dueDate = data.dueDate; const task = await tasksService.updateTask(data.taskId, updateData); @@ -136,6 +138,7 @@ export async function createTask(data: { status?: TaskStatus; priority?: TaskPriority; tags?: string[]; + primaryTagId?: string; }): Promise { try { if (!data.title.trim()) { @@ -147,7 +150,8 @@ export async function createTask(data: { description: data.description?.trim() || '', status: data.status || 'todo', priority: data.priority || 'medium', - tags: data.tags || [] + tags: data.tags || [], + primaryTagId: data.primaryTagId }); // Revalidation automatique du cache diff --git a/src/components/forms/EditTaskForm.tsx b/src/components/forms/EditTaskForm.tsx index 5081dad..519d69b 100644 --- a/src/components/forms/EditTaskForm.tsx +++ b/src/components/forms/EditTaskForm.tsx @@ -19,6 +19,7 @@ interface EditTaskFormProps { status?: TaskStatus; priority?: TaskPriority; tags?: string[]; + primaryTagId?: string; dueDate?: Date; }) => Promise; task: Task | null; @@ -38,6 +39,7 @@ export function EditTaskForm({ status: TaskStatus; priority: TaskPriority; tags: string[]; + primaryTagId?: string; dueDate?: Date; }>({ title: '', @@ -45,6 +47,7 @@ export function EditTaskForm({ status: 'todo' as TaskStatus, priority: 'medium' as TaskPriority, tags: [], + primaryTagId: undefined, dueDate: undefined, }); @@ -59,6 +62,7 @@ export function EditTaskForm({ status: task.status, priority: task.priority, tags: task.tags || [], + primaryTagId: task.primaryTagId, dueDate: task.dueDate, }); } @@ -148,7 +152,9 @@ export function EditTaskForm({ setFormData((prev) => ({ ...prev, tags }))} + onPrimaryTagChange={(primaryTagId) => setFormData((prev) => ({ ...prev, primaryTagId }))} /> {/* Actions */} diff --git a/src/components/forms/task/TaskTagsSection.tsx b/src/components/forms/task/TaskTagsSection.tsx index d9d718b..afac5bc 100644 --- a/src/components/forms/task/TaskTagsSection.tsx +++ b/src/components/forms/task/TaskTagsSection.tsx @@ -6,20 +6,33 @@ import { RelatedTodos } from '@/components/forms/RelatedTodos'; interface TaskTagsSectionProps { taskId: string; tags: string[]; + primaryTagId?: string; onTagsChange: (tags: string[]) => void; + onPrimaryTagChange: (tagId: string | undefined) => void; } -export function TaskTagsSection({ taskId, tags, onTagsChange }: TaskTagsSectionProps) { +export function TaskTagsSection({ + taskId, + tags, + primaryTagId, + onTagsChange, + onPrimaryTagChange +}: TaskTagsSectionProps) { return ( <> {/* Tags */}
); -} +} \ No newline at end of file diff --git a/src/components/ui/TagInput.tsx b/src/components/ui/TagInput.tsx index dd8306c..466e624 100644 --- a/src/components/ui/TagInput.tsx +++ b/src/components/ui/TagInput.tsx @@ -4,11 +4,14 @@ import { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { Tag } from '@/lib/types'; import { useTagsAutocomplete } from '@/hooks/useTags'; +import { tagsClient } from '@/clients/tags-client'; import { Badge } from './Badge'; interface TagInputProps { tags: string[]; onChange: (tags: string[]) => void; + primaryTagId?: string; + onPrimaryTagChange?: (tagId: string | undefined) => void; placeholder?: string; maxTags?: number; className?: string; @@ -18,6 +21,8 @@ interface TagInputProps { export function TagInput({ tags, onChange, + primaryTagId, + onPrimaryTagChange, placeholder = "Ajouter des tags...", maxTags = 10, className = "", @@ -32,6 +37,21 @@ export function TagInput({ const containerRef = useRef(null); const { suggestions, loading, searchTags, clearSuggestions, loadPopularTags } = useTagsAutocomplete(); + const [allTags, setAllTags] = useState([]); + + // Charger tous les tags au début pour pouvoir identifier le tag prioritaire + useEffect(() => { + const loadTags = async () => { + try { + const response = await tagsClient.getPopularTags(100); + setAllTags(response.data); + } catch (error) { + console.error('Erreur lors du chargement des tags:', error); + setAllTags([]); + } + }; + loadTags(); + }, []); // Calculer la position du dropdown const updateDropdownPosition = () => { @@ -87,6 +107,28 @@ export function TagInput({ const removeTag = (tagToRemove: string) => { onChange(tags.filter(tag => tag !== tagToRemove)); + // Si on supprime le tag prioritaire, le désélectionner + if (primaryTagId && onPrimaryTagChange && allTags) { + const tagToRemoveObj = allTags.find(tag => tag.name === tagToRemove); + if (tagToRemoveObj && tagToRemoveObj.id === primaryTagId) { + onPrimaryTagChange(undefined); + } + } + }; + + const handleTagClick = (tagName: string) => { + if (!onPrimaryTagChange || !allTags) return; + + const tagObj = allTags.find(tag => tag.name === tagName); + if (!tagObj) return; + + if (primaryTagId === tagObj.id) { + // Désélectionner si c'est déjà sélectionné + onPrimaryTagChange(undefined); + } else { + // Sélectionner comme prioritaire + onPrimaryTagChange(tagObj.id); + } }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -147,23 +189,40 @@ export function TagInput({
{/* Tags existants */} - {tags.map((tag, index) => ( - - {tag} - - - ))} + {tag} + {isPrimary && } + + + ); + })} {/* Input pour nouveau tag */} {tags.length < maxTags && ( diff --git a/src/components/ui/TaskCard.tsx b/src/components/ui/TaskCard.tsx index 1fd2df2..e9eb77d 100644 --- a/src/components/ui/TaskCard.tsx +++ b/src/components/ui/TaskCard.tsx @@ -4,6 +4,7 @@ import { Card } from './Card'; import { Badge } from './Badge'; import { TagDisplay } from './TagDisplay'; import { formatDateForDisplay } from '@/lib/date-utils'; +import emojiRegex from 'emoji-regex'; interface TaskCardProps extends HTMLAttributes { // Variants @@ -14,6 +15,7 @@ interface TaskCardProps extends HTMLAttributes { title: string; description?: string; tags?: string[]; + primaryTagId?: string; // ID du tag prioritaire priority?: 'low' | 'medium' | 'high' | 'urgent'; // Status & metadata @@ -56,6 +58,7 @@ const TaskCard = forwardRef( title, description, tags = [], + primaryTagId, priority = 'medium', status, dueDate, @@ -203,18 +206,30 @@ const TaskCard = forwardRef( return colors[priority as keyof typeof colors] || colors.medium; }; - // Extraire les emojis du titre - const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu; - const titleEmojis = title.match(emojiRegex) || []; - const titleWithoutEmojis = title.replace(emojiRegex, '').trim(); + // Fonction pour extraire les emojis avec la lib emoji-regex + const extractEmojis = (text: string): string[] => { + const regex = emojiRegex(); + return text.match(regex) || []; + }; - // Si pas d'emoji dans le titre, utiliser l'emoji du premier tag + const titleEmojis = extractEmojis(title); + const titleWithoutEmojis = title.replace(emojiRegex(), '').trim(); + + // Si pas d'emoji dans le titre, utiliser l'emoji du tag prioritaire ou du premier tag let displayEmojis: string[] = titleEmojis; if (displayEmojis.length === 0 && tags && tags.length > 0) { - const firstTag = availableTags.find((tag) => tag.name === tags[0]); - if (firstTag) { - const tagEmojis = firstTag.name.match(emojiRegex); - if (tagEmojis && tagEmojis.length > 0) { + // Priorité au tag prioritaire, sinon premier tag + let tagToUse = null; + if (primaryTagId && availableTags) { + tagToUse = availableTags.find((tag) => tag.id === primaryTagId); + } + if (!tagToUse) { + tagToUse = availableTags.find((tag) => tag.name === tags[0]); + } + + if (tagToUse) { + const tagEmojis = extractEmojis(tagToUse.name); + if (tagEmojis.length > 0) { displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 7881ad0..fc9700d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -43,6 +43,8 @@ export interface Task { sourceId?: string; tags: string[]; tagDetails?: Tag[]; // Tags avec informations complètes (isPinned, etc.) + primaryTagId?: string; // ID du tag prioritaire à afficher en premier + primaryTag?: Tag; // Tag prioritaire avec informations complètes dueDate?: Date; completedAt?: Date; createdAt: Date; diff --git a/src/services/task-management/tasks.ts b/src/services/task-management/tasks.ts index 3def691..2a08ae1 100644 --- a/src/services/task-management/tasks.ts +++ b/src/services/task-management/tasks.ts @@ -38,6 +38,7 @@ export class TasksService { tag: true } }, + primaryTag: true, _count: { select: { dailyCheckboxes: true @@ -65,6 +66,7 @@ export class TasksService { status?: TaskStatus; priority?: TaskPriority; tags?: string[]; + primaryTagId?: string; dueDate?: Date; }): Promise { const status = taskData.status || 'todo'; @@ -75,6 +77,7 @@ export class TasksService { status: status, priority: taskData.priority || 'medium', dueDate: taskData.dueDate, + primaryTagId: taskData.primaryTagId, source: 'manual', // Source manuelle sourceId: `manual-${Date.now()}`, // ID unique // Si créée directement en done/archived, définir completedAt @@ -85,7 +88,8 @@ export class TasksService { include: { tag: true } - } + }, + primaryTag: true } }); @@ -102,7 +106,8 @@ export class TasksService { include: { tag: true } - } + }, + primaryTag: true } }); @@ -118,6 +123,7 @@ export class TasksService { status?: TaskStatus; priority?: TaskPriority; tags?: string[]; + primaryTagId?: string; dueDate?: Date; }): Promise { const task = await prisma.task.findUnique({ @@ -129,12 +135,13 @@ export class TasksService { } // Logique métier : si on marque comme terminé, on ajoute la date - const updateData: Prisma.TaskUpdateInput = { + const updateData: Prisma.TaskUpdateInput & { primaryTagId?: string } = { title: updates.title, description: updates.description, status: updates.status, priority: updates.priority, dueDate: updates.dueDate, + primaryTagId: updates.primaryTagId, updatedAt: getToday() }; @@ -151,7 +158,7 @@ export class TasksService { await prisma.task.update({ where: { id: taskId }, - data: updateData + data: updateData as Prisma.TaskUpdateInput }); // Mettre à jour les relations avec les tags @@ -167,7 +174,8 @@ export class TasksService { include: { tag: true } - } + }, + primaryTag: true } }); @@ -389,6 +397,13 @@ export class TasksService { sourceId: prismaTask.sourceId ?? undefined, tags: tags, tagDetails: tagDetails, + primaryTagId: prismaTask.primaryTagId ?? undefined, + primaryTag: ('primaryTag' in prismaTask && prismaTask.primaryTag) ? { + id: (prismaTask.primaryTag as { id: string }).id, + name: (prismaTask.primaryTag as { name: string }).name, + color: (prismaTask.primaryTag as { color: string }).color, + isPinned: (prismaTask.primaryTag as { isPinned: boolean }).isPinned + } : undefined, dueDate: prismaTask.dueDate ?? undefined, completedAt: prismaTask.completedAt ?? undefined, createdAt: prismaTask.createdAt,