add labels logic
This commit is contained in:
parent
d295a00e2f
commit
e69dd6a56d
6 changed files with 464 additions and 22 deletions
79
frontend/src/components/CardLabels.tsx
Normal file
79
frontend/src/components/CardLabels.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/CreateLabelModal.tsx
Normal file
97
frontend/src/components/CreateLabelModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
frontend/src/components/LabelDropdown.tsx
Normal file
129
frontend/src/components/LabelDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
frontend/src/hooks/useLabelMutations.ts
Normal file
93
frontend/src/hooks/useLabelMutations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
46
frontend/src/hooks/useLabels.ts
Normal file
46
frontend/src/hooks/useLabels.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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() {
|
|||
</div>
|
||||
|
||||
{/* Labels Section */}
|
||||
{card.labels && card.labels.length > 0 && (
|
||||
<div className="bg-gray-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4">
|
||||
<span className="w-5 h-5">
|
||||
<TagIcon />
|
||||
</span>
|
||||
Labels
|
||||
</h2>
|
||||
<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>
|
||||
)}
|
||||
<CardLabels
|
||||
labels={labels}
|
||||
cardLabels={card.labels || []}
|
||||
addLabel={addLabel}
|
||||
addLabelToCard={addLabelToCardMutation}
|
||||
removeLabelFromCard={removeLabelFromCardMutation}
|
||||
refetchLabels={refetchLabels}
|
||||
refetchCard={fetchCard}
|
||||
/>
|
||||
|
||||
{/* Checklists Section */}
|
||||
<CardChecklists
|
||||
|
|
|
|||
Loading…
Reference in a new issue