fix card detail add dropdown, make editing inline

This commit is contained in:
david 2026-04-30 19:11:28 +03:00
parent 2f53b8f4bb
commit f59670397a
7 changed files with 295 additions and 48 deletions

View file

@ -82,6 +82,7 @@ class Card(db.Model):
"id_short": self.id_short, "id_short": self.id_short,
"board_id": self.board_id, "board_id": self.board_id,
"list_id": self.list_id, "list_id": self.list_id,
"list_name": self.list.name,
"epic_id": self.epic_id, "epic_id": self.epic_id,
"date_last_activity": self.date_last_activity.isoformat() "date_last_activity": self.date_last_activity.isoformat()
if self.date_last_activity if self.date_last_activity

View file

@ -42,7 +42,7 @@ def create_card(list_id, lst, body: CardCreateRequest):
@kanban_bp.route("/cards/<int:card_id>", methods=["GET"]) @kanban_bp.route("/cards/<int:card_id>", methods=["GET"])
@jwt_required() @jwt_required()
@load_card_owned @load_card_owned
def get_card(card_id, card): def get_card(card_id, card: Card):
"""Get a single card with full details""" """Get a single card with full details"""
from app.models import User from app.models import User

View file

@ -87,8 +87,11 @@ class CardWithDetailsResponse(BaseModel):
"closed": False, "closed": False,
"pos": 65535.0, "pos": 65535.0,
"list_id": 1, "list_id": 1,
"list_name": "list 1",
"board_id": 1, "board_id": 1,
"due": "2024-12-31T23:59:59", "due": "2024-12-31T23:59:59",
"created_at": "2024-12-31T23:59:59",
"updated_at": "2024-12-31T23:59:59",
"due_complete": False, "due_complete": False,
"badges": {"votes": 0}, "badges": {"votes": 0},
"cover": "https://example.com/cover.jpg", "cover": "https://example.com/cover.jpg",
@ -118,3 +121,6 @@ class CardWithDetailsResponse(BaseModel):
checklists: List[Dict[str, Any]] = Field(default_factory=list) checklists: List[Dict[str, Any]] = Field(default_factory=list)
comments: List[Dict[str, Any]] = Field(default_factory=list) comments: List[Dict[str, Any]] = Field(default_factory=list)
epic: Optional[Dict[str, Any]] = None epic: Optional[Dict[str, Any]] = None
created_at: datetime
updated_at: datetime
list_name: str

View file

@ -0,0 +1,89 @@
import { useState, useRef, useEffect } from 'react';
import Edit2Icon from './icons/Edit2Icon';
import Trash2Icon from './icons/Trash2Icon';
interface CardActionDropdownProps {
onEdit: () => void;
onDelete: () => void;
}
export function CardActionDropdown({ onEdit, onDelete }: CardActionDropdownProps) {
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 handleEdit = () => {
setIsOpen(false);
onEdit();
};
const handleDelete = () => {
setIsOpen(false);
onDelete();
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="text-gray-400 hover:text-white hover:bg-gray-700 p-2 rounded-lg transition-colors"
title="Card actions"
aria-label="Card actions"
aria-expanded={isOpen}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
</button>
{isOpen && (
<div className="absolute top-full right-0 mt-2 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50">
<div className="py-1">
<button
onClick={handleEdit}
className="w-full flex items-center gap-2 px-4 py-2 text-left text-gray-300 hover:text-white hover:bg-gray-700 transition-colors"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
Edit Card
</button>
<button
onClick={handleDelete}
className="w-full flex items-center gap-2 px-4 py-2 text-left text-red-400 hover:text-red-300 hover:bg-red-900/20 transition-colors"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
Delete Card
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,109 @@
import { useState, useRef, useEffect } from 'react';
export function useInlineEditing(
card: { name: string; description?: string | null } | null,
onSave: (name: string, description: string) => Promise<boolean>
) {
// Inline editing state
const [isEditingName, setIsEditingName] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [editedName, setEditedName] = useState('');
const [editedDescription, setEditedDescription] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const descriptionInputRef = useRef<HTMLTextAreaElement>(null);
// Focus input when editing starts
useEffect(() => {
if (isEditingName && nameInputRef.current) {
nameInputRef.current.focus();
}
}, [isEditingName]);
useEffect(() => {
if (isEditingDescription && descriptionInputRef.current) {
descriptionInputRef.current.focus();
}
}, [isEditingDescription]);
// Start editing name
const handleStartEditingName = () => {
if (!card) return;
setEditedName(card.name);
setIsEditingName(true);
};
// Save name
const handleSaveName = async () => {
if (!card) return;
const success = await onSave(editedName, card.description || '');
if (success) {
setIsEditingName(false);
}
};
// Cancel editing name
const handleCancelEditingName = () => {
setIsEditingName(false);
setEditedName(card?.name || '');
};
// Start editing description
const handleStartEditingDescription = () => {
if (!card) return;
setEditedDescription(card.description || '');
setIsEditingDescription(true);
};
// Save description
const handleSaveDescription = async () => {
if (!card) return;
const success = await onSave(card.name, editedDescription);
if (success) {
setIsEditingDescription(false);
}
};
// Cancel editing description
const handleCancelEditingDescription = () => {
setIsEditingDescription(false);
setEditedDescription(card?.description || '');
};
// Handle key press for name input
const handleNameKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSaveName();
} else if (e.key === 'Escape') {
handleCancelEditingName();
}
};
// Handle key press for description input
const handleDescriptionKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancelEditingDescription();
}
};
return {
// State
isEditingName,
isEditingDescription,
editedName,
editedDescription,
nameInputRef,
descriptionInputRef,
// Handlers
handleStartEditingName,
handleSaveName,
handleCancelEditingName,
handleStartEditingDescription,
handleSaveDescription,
handleCancelEditingDescription,
handleNameKeyPress,
handleDescriptionKeyPress,
// Setters
setEditedName,
setEditedDescription,
};
}

View file

@ -5,19 +5,19 @@ import { useChecklistMutations } from '../hooks/useChecklistMutations';
import { useLabels } from '../hooks/useLabels'; import { useLabels } from '../hooks/useLabels';
import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations'; import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations';
import { useModal } from '../context/modals/useModal'; import { useModal } from '../context/modals/useModal';
import { useInlineEditing } from '../hooks/useInlineEditing';
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 { CardLabels } from '../components/CardLabels';
import { CardEpics } from '../components/CardEpics'; import { CardEpics } from '../components/CardEpics';
import { CardAttachments } from '../components/CardAttachments'; import { CardAttachments } from '../components/CardAttachments';
import { EditCardModal } from '../components/EditCardModal';
import { DeleteCardModal } from '../components/DeleteCardModal'; import { DeleteCardModal } from '../components/DeleteCardModal';
import Trash2Icon from '../components/icons/Trash2Icon'; import { CardActionDropdown } from '../components/CardActionDropdown';
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon'; import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
import Edit2Icon from '../components/icons/Edit2Icon';
import { NarrowPageLayout } from '@/components/NarrowPageLayout'; import { NarrowPageLayout } from '@/components/NarrowPageLayout';
import { formatDateTime } from '../utils/dateFormat'; import { formatDateTime } from '../utils/dateFormat';
import Edit2Icon from '../components/icons/Edit2Icon';
export function CardDetail() { export function CardDetail() {
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>(); const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
@ -34,6 +34,9 @@ export function CardDetail() {
const { openModal } = useModal(); const { openModal } = useModal();
const checklistMutations = useChecklistMutations(parseInt(cardId || '0'), fetchCard); const checklistMutations = useChecklistMutations(parseInt(cardId || '0'), fetchCard);
// Inline editing hook
const inlineEditing = useInlineEditing(card, updateCardNameAndDescription);
// Labels functionality // Labels functionality
const { labels, refetch: refetchLabels } = useLabels(parseInt(boardId || '0')); const { labels, refetch: refetchLabels } = useLabels(parseInt(boardId || '0'));
const { addLabel } = useLabelMutations(parseInt(boardId || '0'), refetchLabels); const { addLabel } = useLabelMutations(parseInt(boardId || '0'), refetchLabels);
@ -42,20 +45,6 @@ export function CardDetail() {
fetchCard fetchCard
); );
const handleEditCard = () => {
if (!card) return;
openModal((props) => (
<EditCardModal
card={card}
onSave={async (name, description) => {
return await updateCardNameAndDescription(name, description);
}}
onClose={props.onClose}
/>
));
};
const handleDeleteCard = () => { const handleDeleteCard = () => {
if (!card) return; if (!card) return;
@ -90,31 +79,54 @@ export function CardDetail() {
</span> </span>
Back to Board Back to Board
</Link> </Link>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-2 mt-2">
<h1 className="text-3xl font-bold text-white">{card.name}</h1> {inlineEditing.isEditingName ? (
<button <div className="flex items-center gap-2 flex-1">
onClick={handleEditCard} <input
className="text-gray-400 hover:text-white transition-colors" ref={inlineEditing.nameInputRef}
title="Edit card" type="text"
> value={inlineEditing.editedName}
<span className="w-5 h-5"> onChange={(e) => inlineEditing.setEditedName(e.target.value)}
<Edit2Icon /> onKeyDown={inlineEditing.handleNameKeyPress}
</span> className="flex-1 text-3xl font-bold text-white bg-gray-800 border-2 border-blue-500 rounded-lg px-3 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
</button> />
<button
onClick={inlineEditing.handleSaveName}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg transition-colors"
>
Save
</button>
<button
onClick={inlineEditing.handleCancelEditingName}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded-lg transition-colors"
>
Cancel
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold text-white">{card.name}</h1>
<button
onClick={inlineEditing.handleStartEditingName}
className="text-gray-400 hover:text-white transition-colors"
title="Edit card name"
>
<span className="w-5 h-5">
<Edit2Icon />
</span>
</button>
</div>
)}
</div> </div>
<p className="text-gray-400 text-sm mt-1"> <p className="text-gray-400 text-sm mt-1">
In list Created {formatDateTime(card.created_at)} In list <span className="text-white font-medium">{card.list_name}</span> Created{' '}
{formatDateTime(card.created_at)}
</p> </p>
</div> </div>
<button <CardActionDropdown
onClick={handleDeleteCard} onEdit={inlineEditing.handleStartEditingName}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center gap-2" onDelete={handleDeleteCard}
> />
<span className="w-4 h-4">
<Trash2Icon />
</span>
Delete Card
</button>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@ -123,16 +135,45 @@ export function CardDetail() {
<div className="bg-gray-800 rounded-lg p-6"> <div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">Description</h2> <h2 className="text-xl font-bold text-white flex items-center gap-2">Description</h2>
<button {!inlineEditing.isEditingDescription && (
onClick={handleEditCard} <button
className="text-blue-400 hover:text-blue-300 text-sm font-medium" onClick={inlineEditing.handleStartEditingDescription}
> className="text-blue-400 hover:text-blue-300 text-sm font-medium"
Edit >
</button> Edit
</button>
)}
</div> </div>
<p className="text-gray-300 whitespace-pre-wrap"> {inlineEditing.isEditingDescription ? (
{card.description || 'No description added yet.'} <div className="space-y-3">
</p> <textarea
ref={inlineEditing.descriptionInputRef}
value={inlineEditing.editedDescription}
onChange={(e) => inlineEditing.setEditedDescription(e.target.value)}
onKeyDown={inlineEditing.handleDescriptionKeyPress}
className="w-full min-h-[200px] text-gray-300 bg-gray-700 border-2 border-blue-500 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y"
placeholder="Add a description..."
/>
<div className="flex gap-2">
<button
onClick={inlineEditing.handleSaveDescription}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors"
>
Save
</button>
<button
onClick={inlineEditing.handleCancelEditingDescription}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<p className="text-gray-300 whitespace-pre-wrap">
{card.description || 'No description added yet.'}
</p>
)}
</div> </div>
{/* Labels Section */} {/* Labels Section */}

View file

@ -73,6 +73,7 @@ export interface Card {
id_short: number; id_short: number;
board_id: number; board_id: number;
list_id: number; list_id: number;
list_name: string;
epic_id: number | null; epic_id: number | null;
date_last_activity: string; date_last_activity: string;
created_at: string; created_at: string;