From f59670397a43bd809371c1890ed304cd7ac43380 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 30 Apr 2026 19:11:28 +0300 Subject: [PATCH] fix card detail add dropdown, make editing inline --- backend/app/models/card.py | 1 + backend/app/routes/kanban/cards.py | 2 +- backend/app/schemas/card.py | 6 + .../src/components/CardActionDropdown.tsx | 89 ++++++++++++ frontend/src/hooks/useInlineEditing.ts | 109 ++++++++++++++ frontend/src/pages/CardDetail.tsx | 135 ++++++++++++------ frontend/src/types/kanban.ts | 1 + 7 files changed, 295 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/CardActionDropdown.tsx create mode 100644 frontend/src/hooks/useInlineEditing.ts diff --git a/backend/app/models/card.py b/backend/app/models/card.py index 547ea23..1e3feab 100644 --- a/backend/app/models/card.py +++ b/backend/app/models/card.py @@ -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 diff --git a/backend/app/routes/kanban/cards.py b/backend/app/routes/kanban/cards.py index 1ddaed6..66301f9 100644 --- a/backend/app/routes/kanban/cards.py +++ b/backend/app/routes/kanban/cards.py @@ -42,7 +42,7 @@ def create_card(list_id, lst, body: CardCreateRequest): @kanban_bp.route("/cards/", 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 diff --git a/backend/app/schemas/card.py b/backend/app/schemas/card.py index 81515b8..81c5587 100644 --- a/backend/app/schemas/card.py +++ b/backend/app/schemas/card.py @@ -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 diff --git a/frontend/src/components/CardActionDropdown.tsx b/frontend/src/components/CardActionDropdown.tsx new file mode 100644 index 0000000..6fd9792 --- /dev/null +++ b/frontend/src/components/CardActionDropdown.tsx @@ -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(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 ( +
+ + + {isOpen && ( +
+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useInlineEditing.ts b/frontend/src/hooks/useInlineEditing.ts new file mode 100644 index 0000000..3561e86 --- /dev/null +++ b/frontend/src/hooks/useInlineEditing.ts @@ -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 +) { + // Inline editing state + const [isEditingName, setIsEditingName] = useState(false); + const [isEditingDescription, setIsEditingDescription] = useState(false); + const [editedName, setEditedName] = useState(''); + const [editedDescription, setEditedDescription] = useState(''); + const nameInputRef = useRef(null); + const descriptionInputRef = useRef(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, + }; +} diff --git a/frontend/src/pages/CardDetail.tsx b/frontend/src/pages/CardDetail.tsx index 72a576a..9d09f46 100644 --- a/frontend/src/pages/CardDetail.tsx +++ b/frontend/src/pages/CardDetail.tsx @@ -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) => ( - { - return await updateCardNameAndDescription(name, description); - }} - onClose={props.onClose} - /> - )); - }; - const handleDeleteCard = () => { if (!card) return; @@ -90,31 +79,54 @@ export function CardDetail() { Back to Board -
-

{card.name}

- +
+ {inlineEditing.isEditingName ? ( +
+ 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" + /> + + +
+ ) : ( +
+

{card.name}

+ +
+ )}

- In list • Created {formatDateTime(card.created_at)} + In list {card.list_name} • Created{' '} + {formatDateTime(card.created_at)}

- +
@@ -123,16 +135,45 @@ export function CardDetail() {

Description

- + {!inlineEditing.isEditingDescription && ( + + )}
-

- {card.description || 'No description added yet.'} -

+ {inlineEditing.isEditingDescription ? ( +
+