add labels logic

This commit is contained in:
david 2026-02-27 21:14:02 +03:00
parent d295a00e2f
commit e69dd6a56d
6 changed files with 464 additions and 22 deletions

View file

@ -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<boolean>;
addLabelToCard: (labelId: number) => Promise<boolean>;
removeLabelFromCard: (labelId: number) => Promise<boolean>;
refetchLabels: () => Promise<Label[]>;
refetchCard: () => Promise<any>;
}
export function CardLabels({
labels,
cardLabels,
addLabel,
addLabelToCard,
removeLabelFromCard,
refetchLabels,
refetchCard,
}: CardLabelsProps) {
const { openModal } = useModal();
const handleCreateLabel = () => {
openModal((props) => (
<CreateLabelModal
onSave={async (name, color) => {
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 (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="w-5 h-5">
<TagIcon />
</span>
Labels
</h2>
<LabelDropdown
labels={labels}
cardLabels={cardLabels}
onAddLabel={addLabelToCard}
onRemoveLabel={removeLabelFromCard}
onCreateLabel={handleCreateLabel}
/>
</div>
<div className="flex flex-wrap gap-2">
{cardLabels && cardLabels.length > 0 ? (
cardLabels.map((label) => (
<span
key={label.id}
className="px-3 py-1 rounded-full text-sm font-medium"
style={{ backgroundColor: label.color, color: 'white' }}
>
{label.name}
</span>
))
) : (
<p className="text-gray-400 text-sm">No labels added to this card</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,97 @@
import { useState } from 'react';
interface CreateLabelModalProps {
onSave: (name: string, color: string) => Promise<boolean>;
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 (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Create Label</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Label Name</label>
<input
type="text"
value={name}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Color</label>
<div className="grid grid-cols-4 gap-2">
{COLORS.map((colorOption) => (
<button
key={colorOption.value}
type="button"
onClick={() => setColor(colorOption.value)}
className={`h-10 rounded-lg border-2 transition-all ${
color === colorOption.value
? 'border-white ring-2 ring-white/50'
: 'border-transparent hover:border-gray-500'
}`}
style={{ backgroundColor: colorOption.value }}
title={colorOption.name}
/>
))}
</div>
</div>
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Preview</label>
<div className="flex items-center gap-2">
<span
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: color }}
>
{name || 'Label Preview'}
</span>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Create
</button>
</div>
</form>
</div>
);
}

View file

@ -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<boolean>;
onRemoveLabel: (labelId: number) => Promise<boolean>;
onCreateLabel: () => void;
}
export function LabelDropdown({
labels,
cardLabels,
onAddLabel,
onRemoveLabel,
onCreateLabel,
}: LabelDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white px-3 py-1.5 rounded-lg transition-colors text-sm"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Add Label
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-2 w-72 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50">
<div className="p-4">
<h4 className="text-white font-medium mb-3">Labels</h4>
{/* Add new label button */}
<button
onClick={() => {
onCreateLabel();
setIsOpen(false);
}}
className="w-full mb-3 flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg transition-colors text-sm"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Create new label
</button>
{/* Available labels */}
{availableLabels.length > 0 ? (
<div className="space-y-2">
<p className="text-gray-400 text-xs uppercase tracking-wide">Available Labels</p>
{availableLabels.map((label) => (
<button
key={label.id}
onClick={() => handleToggleLabel(label.id, false)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-700 transition-colors text-left"
>
<span
className="w-6 h-6 rounded-full"
style={{ backgroundColor: label.color }}
/>
<span className="text-gray-200 text-sm">{label.name}</span>
</button>
))}
</div>
) : (
<p className="text-gray-400 text-sm">No labels available</p>
)}
{/* Current labels (can be removed) */}
{cardLabels.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-700 space-y-2">
<p className="text-gray-400 text-xs uppercase tracking-wide">Current Labels</p>
{cardLabels.map((label) => (
<button
key={label.id}
onClick={() => handleToggleLabel(label.id, true)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-700 transition-colors text-left"
>
<span
className="w-6 h-6 rounded-full border-2 border-white"
style={{ backgroundColor: label.color }}
/>
<span className="text-gray-200 text-sm">{label.name}</span>
<span className="ml-auto text-xs text-gray-400">Remove</span>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -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,
};
}

View file

@ -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<Label[]>([]);
const [error, setError] = useState<Error | null>(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,
};
}

View file

@ -2,13 +2,15 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCard } from '../hooks/useCard'; import { useCard } from '../hooks/useCard';
import { useCardDetailMutations } from '../hooks/useCardDetailMutations'; import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
import { useChecklistMutations } from '../hooks/useChecklistMutations'; import { useChecklistMutations } from '../hooks/useChecklistMutations';
import { useLabels } from '../hooks/useLabels';
import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations';
import { useModal } from '../context/modals/useModal'; import { useModal } from '../context/modals/useModal';
import { CardSidebar } from '../components/CardSidebar'; import { CardSidebar } from '../components/CardSidebar';
import { CardComments } from '../components/CardComments'; import { CardComments } from '../components/CardComments';
import { CardChecklists } from '../components/CardChecklists'; import { CardChecklists } from '../components/CardChecklists';
import { CardLabels } from '../components/CardLabels';
import { EditCardModal } from '../components/EditCardModal'; import { EditCardModal } from '../components/EditCardModal';
import { DeleteCardModal } from '../components/DeleteCardModal'; import { DeleteCardModal } from '../components/DeleteCardModal';
import TagIcon from '../components/icons/TagIcon';
import Trash2Icon from '../components/icons/Trash2Icon'; import Trash2Icon from '../components/icons/Trash2Icon';
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon'; import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
import Edit2Icon from '../components/icons/Edit2Icon'; import Edit2Icon from '../components/icons/Edit2Icon';
@ -28,6 +30,14 @@ export function CardDetail() {
const { openModal } = useModal(); const { openModal } = useModal();
const checklistMutations = useChecklistMutations(parseInt(cardId || '0'), fetchCard); 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 = () => { const handleEditCard = () => {
if (!card) return; if (!card) return;
@ -132,27 +142,15 @@ export function CardDetail() {
</div> </div>
{/* Labels Section */} {/* Labels Section */}
{card.labels && card.labels.length > 0 && ( <CardLabels
<div className="bg-gray-800 rounded-lg p-6"> labels={labels}
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4"> cardLabels={card.labels || []}
<span className="w-5 h-5"> addLabel={addLabel}
<TagIcon /> addLabelToCard={addLabelToCardMutation}
</span> removeLabelFromCard={removeLabelFromCardMutation}
Labels refetchLabels={refetchLabels}
</h2> refetchCard={fetchCard}
<div className="flex flex-wrap gap-2"> />
{card.labels.map((label: any) => (
<span
key={label.id}
className="px-3 py-1 rounded-full text-sm font-medium"
style={{ backgroundColor: label.color, color: 'white' }}
>
{label.name}
</span>
))}
</div>
</div>
)}
{/* Checklists Section */} {/* Checklists Section */}
<CardChecklists <CardChecklists