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,
|
"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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 { 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">
|
||||||
|
{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>
|
<h1 className="text-3xl font-bold text-white">{card.name}</h1>
|
||||||
<button
|
<button
|
||||||
onClick={handleEditCard}
|
onClick={inlineEditing.handleStartEditingName}
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
title="Edit card"
|
title="Edit card name"
|
||||||
>
|
>
|
||||||
<span className="w-5 h-5">
|
<span className="w-5 h-5">
|
||||||
<Edit2Icon />
|
<Edit2Icon />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
|
{!inlineEditing.isEditingDescription && (
|
||||||
<button
|
<button
|
||||||
onClick={handleEditCard}
|
onClick={inlineEditing.handleStartEditingDescription}
|
||||||
className="text-blue-400 hover:text-blue-300 text-sm font-medium"
|
className="text-blue-400 hover:text-blue-300 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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">
|
<p className="text-gray-300 whitespace-pre-wrap">
|
||||||
{card.description || 'No description added yet.'}
|
{card.description || 'No description added yet.'}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labels Section */}
|
{/* Labels Section */}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue