fix card detail add dropdown, make editing inline
This commit is contained in:
parent
2f53b8f4bb
commit
f59670397a
7 changed files with 295 additions and 48 deletions
|
|
@ -82,6 +82,7 @@ class Card(db.Model):
|
|||
"id_short": self.id_short,
|
||||
"board_id": self.board_id,
|
||||
"list_id": self.list_id,
|
||||
"list_name": self.list.name,
|
||||
"epic_id": self.epic_id,
|
||||
"date_last_activity": self.date_last_activity.isoformat()
|
||||
if self.date_last_activity
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ def create_card(list_id, lst, body: CardCreateRequest):
|
|||
@kanban_bp.route("/cards/<int:card_id>", methods=["GET"])
|
||||
@jwt_required()
|
||||
@load_card_owned
|
||||
def get_card(card_id, card):
|
||||
def get_card(card_id, card: Card):
|
||||
"""Get a single card with full details"""
|
||||
from app.models import User
|
||||
|
||||
|
|
|
|||
|
|
@ -87,8 +87,11 @@ class CardWithDetailsResponse(BaseModel):
|
|||
"closed": False,
|
||||
"pos": 65535.0,
|
||||
"list_id": 1,
|
||||
"list_name": "list 1",
|
||||
"board_id": 1,
|
||||
"due": "2024-12-31T23:59:59",
|
||||
"created_at": "2024-12-31T23:59:59",
|
||||
"updated_at": "2024-12-31T23:59:59",
|
||||
"due_complete": False,
|
||||
"badges": {"votes": 0},
|
||||
"cover": "https://example.com/cover.jpg",
|
||||
|
|
@ -118,3 +121,6 @@ class CardWithDetailsResponse(BaseModel):
|
|||
checklists: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
comments: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
epic: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
list_name: str
|
||||
|
|
|
|||
89
frontend/src/components/CardActionDropdown.tsx
Normal file
89
frontend/src/components/CardActionDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
frontend/src/hooks/useInlineEditing.ts
Normal file
109
frontend/src/hooks/useInlineEditing.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -5,19 +5,19 @@ import { useChecklistMutations } from '../hooks/useChecklistMutations';
|
|||
import { useLabels } from '../hooks/useLabels';
|
||||
import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations';
|
||||
import { useModal } from '../context/modals/useModal';
|
||||
import { useInlineEditing } from '../hooks/useInlineEditing';
|
||||
import { CardSidebar } from '../components/CardSidebar';
|
||||
import { CardComments } from '../components/CardComments';
|
||||
import { CardChecklists } from '../components/CardChecklists';
|
||||
import { CardLabels } from '../components/CardLabels';
|
||||
import { CardEpics } from '../components/CardEpics';
|
||||
import { CardAttachments } from '../components/CardAttachments';
|
||||
import { EditCardModal } from '../components/EditCardModal';
|
||||
import { DeleteCardModal } from '../components/DeleteCardModal';
|
||||
import Trash2Icon from '../components/icons/Trash2Icon';
|
||||
import { CardActionDropdown } from '../components/CardActionDropdown';
|
||||
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
|
||||
import Edit2Icon from '../components/icons/Edit2Icon';
|
||||
import { NarrowPageLayout } from '@/components/NarrowPageLayout';
|
||||
import { formatDateTime } from '../utils/dateFormat';
|
||||
import Edit2Icon from '../components/icons/Edit2Icon';
|
||||
|
||||
export function CardDetail() {
|
||||
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
|
||||
|
|
@ -34,6 +34,9 @@ export function CardDetail() {
|
|||
const { openModal } = useModal();
|
||||
const checklistMutations = useChecklistMutations(parseInt(cardId || '0'), fetchCard);
|
||||
|
||||
// Inline editing hook
|
||||
const inlineEditing = useInlineEditing(card, updateCardNameAndDescription);
|
||||
|
||||
// Labels functionality
|
||||
const { labels, refetch: refetchLabels } = useLabels(parseInt(boardId || '0'));
|
||||
const { addLabel } = useLabelMutations(parseInt(boardId || '0'), refetchLabels);
|
||||
|
|
@ -42,20 +45,6 @@ export function CardDetail() {
|
|||
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 = () => {
|
||||
if (!card) return;
|
||||
|
||||
|
|
@ -90,31 +79,54 @@ export function CardDetail() {
|
|||
</span>
|
||||
Back to Board
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{inlineEditing.isEditingName ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
ref={inlineEditing.nameInputRef}
|
||||
type="text"
|
||||
value={inlineEditing.editedName}
|
||||
onChange={(e) => inlineEditing.setEditedName(e.target.value)}
|
||||
onKeyDown={inlineEditing.handleNameKeyPress}
|
||||
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
|
||||
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={handleEditCard}
|
||||
onClick={inlineEditing.handleStartEditingName}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="Edit card"
|
||||
title="Edit card name"
|
||||
>
|
||||
<span className="w-5 h-5">
|
||||
<Edit2Icon />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDeleteCard}
|
||||
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"
|
||||
>
|
||||
<span className="w-4 h-4">
|
||||
<Trash2Icon />
|
||||
</span>
|
||||
Delete Card
|
||||
</button>
|
||||
<CardActionDropdown
|
||||
onEdit={inlineEditing.handleStartEditingName}
|
||||
onDelete={handleDeleteCard}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">Description</h2>
|
||||
{!inlineEditing.isEditingDescription && (
|
||||
<button
|
||||
onClick={handleEditCard}
|
||||
onClick={inlineEditing.handleStartEditingDescription}
|
||||
className="text-blue-400 hover:text-blue-300 text-sm font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{inlineEditing.isEditingDescription ? (
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
|
||||
{/* Labels Section */}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export interface Card {
|
|||
id_short: number;
|
||||
board_id: number;
|
||||
list_id: number;
|
||||
list_name: string;
|
||||
epic_id: number | null;
|
||||
date_last_activity: string;
|
||||
created_at: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue