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.
This commit is contained in:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ export async function updateTask(data: {
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
tags?: string[];
|
||||
primaryTagId?: string;
|
||||
dueDate?: Date;
|
||||
}): Promise<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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
|
||||
|
||||
@@ -19,6 +19,7 @@ interface EditTaskFormProps {
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
tags?: string[];
|
||||
primaryTagId?: string;
|
||||
dueDate?: Date;
|
||||
}) => Promise<void>;
|
||||
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({
|
||||
<TaskTagsSection
|
||||
taskId={task.id}
|
||||
tags={formData.tags}
|
||||
primaryTagId={formData.primaryTagId}
|
||||
onTagsChange={(tags) => setFormData((prev) => ({ ...prev, tags }))}
|
||||
onPrimaryTagChange={(primaryTagId) => setFormData((prev) => ({ ...prev, primaryTagId }))}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -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 */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Tags
|
||||
<span className="text-xs normal-case ml-2 text-[var(--muted-foreground)]">
|
||||
(cliquer sur un tag pour le sélectionner comme prioritaire)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<TagInput
|
||||
tags={tags || []}
|
||||
primaryTagId={primaryTagId}
|
||||
onPrimaryTagChange={onPrimaryTagChange}
|
||||
onChange={onTagsChange}
|
||||
placeholder="Ajouter des tags..."
|
||||
maxTags={10}
|
||||
|
||||
@@ -73,6 +73,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
||||
title={task.title}
|
||||
description={task.description}
|
||||
tags={task.tags}
|
||||
primaryTagId={task.primaryTagId}
|
||||
priority={task.priority}
|
||||
status={task.status}
|
||||
dueDate={task.dueDate}
|
||||
|
||||
@@ -156,4 +156,4 @@ export function TagList({
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
const { suggestions, loading, searchTags, clearSuggestions, loadPopularTags } = useTagsAutocomplete();
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
|
||||
// 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({
|
||||
<div className="min-h-[42px] p-2 border border-[var(--border)] rounded-lg bg-[var(--input)] focus-within:border-[var(--primary)] focus-within:ring-1 focus-within:ring-[var(--primary)]/20 transition-colors">
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{/* Tags existants */}
|
||||
{tags.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="default"
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs"
|
||||
>
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] ml-1"
|
||||
aria-label={`Supprimer le tag ${tag}`}
|
||||
{tags.map((tag, index) => {
|
||||
const tagObj = allTags?.find(t => t.name === tag);
|
||||
const isPrimary = tagObj && primaryTagId === tagObj.id;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="default"
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs transition-all ${
|
||||
isPrimary
|
||||
? 'ring-2 ring-[var(--primary)] bg-[var(--primary)]/10'
|
||||
: onPrimaryTagChange
|
||||
? 'cursor-pointer hover:ring-1 hover:ring-[var(--primary)]/50'
|
||||
: ''
|
||||
}`}
|
||||
onClick={onPrimaryTagChange ? () => handleTagClick(tag) : undefined}
|
||||
title={onPrimaryTagChange ? (isPrimary ? 'Tag prioritaire (cliquer pour désélectionner)' : 'Cliquer pour sélectionner comme prioritaire') : undefined}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<span>{tag}</span>
|
||||
{isPrimary && <span className="text-[var(--primary)] font-bold">★</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] ml-1"
|
||||
aria-label={`Supprimer le tag ${tag}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Input pour nouveau tag */}
|
||||
{tags.length < maxTags && (
|
||||
|
||||
@@ -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<HTMLDivElement> {
|
||||
// Variants
|
||||
@@ -14,6 +15,7 @@ interface TaskCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
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<HTMLDivElement, TaskCardProps>(
|
||||
title,
|
||||
description,
|
||||
tags = [],
|
||||
primaryTagId,
|
||||
priority = 'medium',
|
||||
status,
|
||||
dueDate,
|
||||
@@ -203,18 +206,30 @@ const TaskCard = forwardRef<HTMLDivElement, TaskCardProps>(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Task> {
|
||||
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<Task> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user