From e69dd6a56d6afa94fac8c19d1325ea0db68a8588 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 27 Feb 2026 21:14:02 +0300 Subject: [PATCH] add labels logic --- frontend/src/components/CardLabels.tsx | 79 ++++++++++++ frontend/src/components/CreateLabelModal.tsx | 97 ++++++++++++++ frontend/src/components/LabelDropdown.tsx | 129 +++++++++++++++++++ frontend/src/hooks/useLabelMutations.ts | 93 +++++++++++++ frontend/src/hooks/useLabels.ts | 46 +++++++ frontend/src/pages/CardDetail.tsx | 42 +++--- 6 files changed, 464 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/CardLabels.tsx create mode 100644 frontend/src/components/CreateLabelModal.tsx create mode 100644 frontend/src/components/LabelDropdown.tsx create mode 100644 frontend/src/hooks/useLabelMutations.ts create mode 100644 frontend/src/hooks/useLabels.ts diff --git a/frontend/src/components/CardLabels.tsx b/frontend/src/components/CardLabels.tsx new file mode 100644 index 0000000..8885e27 --- /dev/null +++ b/frontend/src/components/CardLabels.tsx @@ -0,0 +1,79 @@ +import { useModal } from '../context/modals/useModal'; +import { CreateLabelModal } from './CreateLabelModal'; +import { LabelDropdown } from './LabelDropdown'; +import TagIcon from './icons/TagIcon'; +import { Label } from '../types/kanban'; + +interface CardLabelsProps { + labels: Label[]; + cardLabels: Label[]; + addLabel: (name: string, color: string) => Promise; + addLabelToCard: (labelId: number) => Promise; + removeLabelFromCard: (labelId: number) => Promise; + refetchLabels: () => Promise; + refetchCard: () => Promise; +} + +export function CardLabels({ + labels, + cardLabels, + addLabel, + addLabelToCard, + removeLabelFromCard, + refetchLabels, + refetchCard, +}: CardLabelsProps) { + const { openModal } = useModal(); + + const handleCreateLabel = () => { + openModal((props) => ( + { + const success = await addLabel(name, color); + if (success) { + // Refetch both labels and card data to show new labels + await refetchLabels(); + await refetchCard(); + } + return success; + }} + onClose={props.onClose} + /> + )); + }; + + return ( +
+
+

+ + + + Labels +

+ +
+
+ {cardLabels && cardLabels.length > 0 ? ( + cardLabels.map((label) => ( + + {label.name} + + )) + ) : ( +

No labels added to this card

+ )} +
+
+ ); +} diff --git a/frontend/src/components/CreateLabelModal.tsx b/frontend/src/components/CreateLabelModal.tsx new file mode 100644 index 0000000..1ea45d8 --- /dev/null +++ b/frontend/src/components/CreateLabelModal.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; + +interface CreateLabelModalProps { + onSave: (name: string, color: string) => Promise; + onClose: () => void; +} + +const COLORS = [ + { name: 'Red', value: '#ef4444' }, + { name: 'Orange', value: '#f97316' }, + { name: 'Yellow', value: '#eab308' }, + { name: 'Green', value: '#22c55e' }, + { name: 'Blue', value: '#3b82f6' }, + { name: 'Purple', value: '#a855f7' }, + { name: 'Pink', value: '#ec4899' }, + { name: 'Gray', value: '#6b7280' }, +]; + +export function CreateLabelModal({ onSave, onClose }: CreateLabelModalProps) { + const [name, setName] = useState(''); + const [color, setColor] = useState(COLORS[0].value); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + const success = await onSave(name, color); + if (success) { + onClose(); + } + }; + + return ( +
+

Create Label

+
+
+ + setName(e.target.value)} + className="w-full bg-gray-700 text-white rounded-lg p-3 border border-gray-600 focus:border-blue-500 focus:outline-none" + placeholder="Enter label name..." + autoFocus + /> +
+
+ +
+ {COLORS.map((colorOption) => ( +
+
+
+ +
+ + {name || 'Label Preview'} + +
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/LabelDropdown.tsx b/frontend/src/components/LabelDropdown.tsx new file mode 100644 index 0000000..fab839b --- /dev/null +++ b/frontend/src/components/LabelDropdown.tsx @@ -0,0 +1,129 @@ +import { useState, useRef, useEffect } from 'react'; +import { Label } from '../types/kanban'; +import PlusIcon from './icons/PlusIcon'; + +interface LabelDropdownProps { + labels: Label[]; + cardLabels: Label[]; + onAddLabel: (labelId: number) => Promise; + onRemoveLabel: (labelId: number) => Promise; + onCreateLabel: () => void; +} + +export function LabelDropdown({ + labels, + cardLabels, + onAddLabel, + onRemoveLabel, + onCreateLabel, +}: LabelDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + const cardLabelIds = new Set(cardLabels.map((l) => l.id)); + const availableLabels = labels.filter((l) => !cardLabelIds.has(l.id)); + + const handleToggleLabel = async (labelId: number, isAdded: boolean) => { + let success = false; + if (isAdded) { + success = await onRemoveLabel(labelId); + } else { + success = await onAddLabel(labelId); + } + if (success) { + setIsOpen(false); + } + }; + + return ( +
+ + + {isOpen && ( +
+
+

Labels

+ + {/* Add new label button */} + + + {/* Available labels */} + {availableLabels.length > 0 ? ( +
+

Available Labels

+ {availableLabels.map((label) => ( + + ))} +
+ ) : ( +

No labels available

+ )} + + {/* Current labels (can be removed) */} + {cardLabels.length > 0 && ( +
+

Current Labels

+ {cardLabels.map((label) => ( + + ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useLabelMutations.ts b/frontend/src/hooks/useLabelMutations.ts new file mode 100644 index 0000000..28e95f5 --- /dev/null +++ b/frontend/src/hooks/useLabelMutations.ts @@ -0,0 +1,93 @@ +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; + +export function useLabelMutations(boardId: number, onUpdate: () => void) { + const { createLabel } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const addLabel = async (name: string, color: string) => { + try { + await withLoader(() => createLabel(boardId, { name, color }), 'Creating label...'); + onUpdate(); + addNotification({ + type: 'success', + title: 'Label Created', + message: 'Label has been created successfully.', + duration: 3000, + }); + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create label'; + addNotification({ + type: 'error', + title: 'Error', + message: errorMessage, + duration: 5000, + }); + return false; + } + }; + + return { + addLabel, + }; +} + +export function useCardLabelMutations(cardId: number, onUpdate: () => void) { + const { addLabelToCard, removeLabelFromCard } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const addLabelToCardMutation = async (labelId: number) => { + try { + await withLoader(() => addLabelToCard(cardId, labelId), 'Adding label...'); + onUpdate(); + addNotification({ + type: 'success', + title: 'Label Added', + message: 'Label has been added to the card.', + duration: 3000, + }); + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to add label'; + addNotification({ + type: 'error', + title: 'Error', + message: errorMessage, + duration: 5000, + }); + return false; + } + }; + + const removeLabelFromCardMutation = async (labelId: number) => { + try { + await withLoader(() => removeLabelFromCard(cardId, labelId), 'Removing label...'); + onUpdate(); + addNotification({ + type: 'success', + title: 'Label Removed', + message: 'Label has been removed from the card.', + duration: 3000, + }); + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to remove label'; + addNotification({ + type: 'error', + title: 'Error', + message: errorMessage, + duration: 5000, + }); + return false; + } + }; + + return { + addLabelToCardMutation, + removeLabelFromCardMutation, + }; +} diff --git a/frontend/src/hooks/useLabels.ts b/frontend/src/hooks/useLabels.ts new file mode 100644 index 0000000..6d6b9a0 --- /dev/null +++ b/frontend/src/hooks/useLabels.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import { Label } from '../types/kanban'; + +export function useLabels(boardId: number) { + const [labels, setLabels] = useState([]); + const [error, setError] = useState(null); + + const { getLabels } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const fetchLabels = useCallback(async () => { + try { + setError(null); + const data = await withLoader(() => getLabels(boardId), 'Loading labels...'); + setLabels(data); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load labels'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Loading Labels', + message: errorMessage, + duration: 5000, + }); + return []; + } + }, [boardId, getLabels, withLoader, addNotification]); + + useEffect(() => { + if (boardId) { + fetchLabels(); + } + }, [boardId, fetchLabels]); + + return { + labels, + error, // For debugging, not for UI display + loading: false, // Loading is handled by global loader + refetch: fetchLabels, + }; +} diff --git a/frontend/src/pages/CardDetail.tsx b/frontend/src/pages/CardDetail.tsx index 5a8cc08..f212fac 100644 --- a/frontend/src/pages/CardDetail.tsx +++ b/frontend/src/pages/CardDetail.tsx @@ -2,13 +2,15 @@ import { useParams, Link, useNavigate } from 'react-router-dom'; import { useCard } from '../hooks/useCard'; import { useCardDetailMutations } from '../hooks/useCardDetailMutations'; import { useChecklistMutations } from '../hooks/useChecklistMutations'; +import { useLabels } from '../hooks/useLabels'; +import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations'; import { useModal } from '../context/modals/useModal'; import { CardSidebar } from '../components/CardSidebar'; import { CardComments } from '../components/CardComments'; import { CardChecklists } from '../components/CardChecklists'; +import { CardLabels } from '../components/CardLabels'; import { EditCardModal } from '../components/EditCardModal'; import { DeleteCardModal } from '../components/DeleteCardModal'; -import TagIcon from '../components/icons/TagIcon'; import Trash2Icon from '../components/icons/Trash2Icon'; import ArrowLeftIcon from '../components/icons/ArrowLeftIcon'; import Edit2Icon from '../components/icons/Edit2Icon'; @@ -28,6 +30,14 @@ export function CardDetail() { const { openModal } = useModal(); const checklistMutations = useChecklistMutations(parseInt(cardId || '0'), fetchCard); + // Labels functionality + const { labels, refetch: refetchLabels } = useLabels(parseInt(boardId || '0')); + const { addLabel } = useLabelMutations(parseInt(boardId || '0'), refetchLabels); + const { addLabelToCardMutation, removeLabelFromCardMutation } = useCardLabelMutations( + parseInt(cardId || '0'), + fetchCard + ); + const handleEditCard = () => { if (!card) return; @@ -132,27 +142,15 @@ export function CardDetail() { {/* Labels Section */} - {card.labels && card.labels.length > 0 && ( -
-

- - - - Labels -

-
- {card.labels.map((label: any) => ( - - {label.name} - - ))} -
-
- )} + {/* Checklists Section */}