add card hierarchy, adding linked card and converting checklist item to card

This commit is contained in:
david 2026-04-30 21:19:11 +03:00
parent f59670397a
commit 543c28984f
22 changed files with 1262 additions and 41 deletions

View file

@ -1,6 +1,8 @@
# fmt: off
from app.models.board import Board
from app.models.card import Card
from app.models.card_label import CardLabel
from app.models.card_link import CardLink
from app.models.check_item import CheckItem
from app.models.checklist import Checklist
from app.models.comment import Comment
@ -18,6 +20,7 @@ __all__ = [
"Card",
"Label",
"CardLabel",
"CardLink",
"Checklist",
"CheckItem",
"Comment",
@ -26,3 +29,4 @@ __all__ = [
"Wiki",
"wiki_entity_links",
]
# fmt: on

View file

@ -69,9 +69,25 @@ class Card(db.Model):
lazy="dynamic",
)
def to_dict(self):
# Card link relationships (self-referential many-to-many)
child_links = db.relationship(
"CardLink",
foreign_keys="CardLink.parent_card_id",
back_populates="parent_card",
cascade="all, delete-orphan",
lazy="dynamic",
)
parent_links = db.relationship(
"CardLink",
foreign_keys="CardLink.child_card_id",
back_populates="child_card",
cascade="all, delete-orphan",
lazy="dynamic",
)
def to_dict(self, include_linked=False):
"""Convert card to dictionary"""
return {
result = {
"id": self.id,
"name": self.name,
"description": self.description,
@ -82,7 +98,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,
"list_name": self.list.name if self.list else None,
"epic_id": self.epic_id,
"date_last_activity": self.date_last_activity.isoformat()
if self.date_last_activity
@ -92,7 +108,24 @@ class Card(db.Model):
"badges": self.badges,
"cover": self.cover,
"desc_data": self.desc_data,
"parent_card_name": (
pl.parent_card.name
if (pl := self.parent_links.first()) and pl.parent_card
else None
),
}
if include_linked:
result["parent_cards"] = [
link.child_card.to_dict()
for link in self.parent_links
if link.child_card
]
result["child_cards"] = [
link.child_card.to_dict()
for link in self.child_links
if link.child_card
]
return result
def __repr__(self):
return f"<Card {self.name}>"
@ -106,19 +139,16 @@ def update_epic_metrics_on_card_change(mapper, connection, target):
from app.models import Epic
# Get total card count
card_count_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id
)
card_count = connection.execute(card_count_stmt).scalar()
# Get epic's completed_list_id
completed_list_id_stmt = select(Epic.completed_list_id).where(
Epic.id == target.epic_id
)
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
# Get completed card count (only if epic has completed_list_id)
completed_cards_count = 0
if completed_list_id:
completed_cards_stmt = select(db.func.count(Card.id)).where(
@ -126,7 +156,6 @@ def update_epic_metrics_on_card_change(mapper, connection, target):
)
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
# Update epic metrics
connection.execute(
update(Epic)
.where(Epic.id == target.epic_id)
@ -146,19 +175,16 @@ def update_epic_metrics_on_card_insert(mapper, connection, target):
from app.models import Epic
# Get total card count
card_count_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id
)
card_count = connection.execute(card_count_stmt).scalar()
# Get epic's completed_list_id
completed_list_id_stmt = select(Epic.completed_list_id).where(
Epic.id == target.epic_id
)
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
# Get completed card count (only if epic has completed_list_id)
completed_cards_count = 0
if completed_list_id:
completed_cards_stmt = select(db.func.count(Card.id)).where(
@ -166,7 +192,6 @@ def update_epic_metrics_on_card_insert(mapper, connection, target):
)
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
# Update epic metrics
connection.execute(
update(Epic)
.where(Epic.id == target.epic_id)
@ -186,19 +211,16 @@ def update_epic_metrics_on_card_delete(mapper, connection, target):
from app.models import Epic
# Get total card count
card_count_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id
)
card_count = connection.execute(card_count_stmt).scalar()
# Get epic's completed_list_id
completed_list_id_stmt = select(Epic.completed_list_id).where(
Epic.id == target.epic_id
)
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
# Get completed card count (only if epic has completed_list_id)
completed_cards_count = 0
if completed_list_id:
completed_cards_stmt = select(db.func.count(Card.id)).where(
@ -206,7 +228,6 @@ def update_epic_metrics_on_card_delete(mapper, connection, target):
)
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
# Update epic metrics
connection.execute(
update(Epic)
.where(Epic.id == target.epic_id)

View file

@ -0,0 +1,66 @@
from datetime import UTC, datetime
from app import db
class CardLink(db.Model):
"""CardLink model for bidirectional card-to-card relationships"""
__tablename__ = "card_links"
id = db.Column(db.Integer, primary_key=True)
parent_card_id = db.Column(
db.Integer,
db.ForeignKey("cards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
child_card_id = db.Column(
db.Integer,
db.ForeignKey("cards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by = db.Column(
db.Integer, db.ForeignKey("users.id"), nullable=True, index=True
)
# Timestamps
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
# Relationships
parent_card = db.relationship(
"Card",
foreign_keys=[parent_card_id],
back_populates="child_links",
)
child_card = db.relationship(
"Card",
foreign_keys=[child_card_id],
back_populates="parent_links",
)
def to_dict(self, include_cards=False):
"""Convert card link to dictionary"""
result = {
"id": self.id,
"parent_card_id": self.parent_card_id,
"child_card_id": self.child_card_id,
"created_by": self.created_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
if include_cards:
result["parent_card"] = (
self.parent_card.to_dict() if self.parent_card else None
)
result["child_card"] = (
self.child_card.to_dict() if self.child_card else None
)
return result
def __repr__(self):
return f"<CardLink {self.parent_card_id} -> {self.child_card_id}>"
__table_args__ = (
db.UniqueConstraint("parent_card_id", "child_card_id", name="unique_card_link"),
)

View file

@ -1,14 +1,12 @@
# fmt: off
from flask import Blueprint
# Create the kanban blueprint that will be used by all route modules
kanban_bp = Blueprint("kanban", __name__)
# Import all route modules to register their routes to this blueprint
# fmt: off
from . import (boards, cards, checklists, comments, epics, # noqa: F401 E402
files, labels, lists, wikis)
# fmt: on
from . import (boards, card_links, cards, checklists, # noqa: F401 E402
comments, epics, files, labels, lists, wikis)
__all__ = ["kanban_bp"]
# fmt: on

View file

@ -0,0 +1,279 @@
"""Routes for Card-to-Card linking operations."""
from flask import jsonify, request
from flask_jwt_extended import get_jwt_identity, jwt_required
from app import db
from app.models import Card
from app.models.card_label import CardLabel
from app.models.card_link import CardLink
from . import kanban_bp
@kanban_bp.route("/cards/<int:card_id>/links", methods=["GET"])
@jwt_required()
def get_card_links(card_id):
"""Get all cards linked to a card (both parent and child)."""
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
# Cards where this card is the parent (this card -> child cards)
child_links = card.child_links.all()
# Cards where this card is the child (parent cards -> this card)
parent_links = card.parent_links.all()
return (
jsonify(
{
"child_cards": [
{
"id": link.id,
"card": link.child_card.to_dict(),
"created_at": (
link.created_at.isoformat() if link.created_at else None
),
}
for link in child_links
if link.child_card
],
"parent_cards": [
{
"id": link.id,
"card": link.parent_card.to_dict(),
"created_at": (
link.created_at.isoformat() if link.created_at else None
),
}
for link in parent_links
if link.parent_card
],
}
),
200,
)
@kanban_bp.route("/cards/<int:card_id>/links", methods=["POST"])
@jwt_required()
def link_existing_card(card_id):
"""Link an existing card to this card."""
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
data = request.get_json()
target_card_id = data.get("child_card_id")
if not target_card_id:
return jsonify({"error": "child_card_id is required"}), 400
target_card = db.session.get(Card, target_card_id)
if not target_card:
return jsonify({"error": "Target card not found"}), 404
if card.id == target_card.id:
return jsonify({"error": "Cannot link a card to itself"}), 400
# Check if link already exists (in either direction)
existing = CardLink.query.filter(
db.or_(
db.and_(
CardLink.parent_card_id == card.id,
CardLink.child_card_id == target_card.id,
),
db.and_(
CardLink.parent_card_id == target_card.id,
CardLink.child_card_id == card.id,
),
)
).first()
if existing:
return jsonify({"error": "Cards are already linked"}), 409
user_id = get_jwt_identity()
# Create link: card is parent, target is child
link = CardLink(
parent_card_id=card.id,
child_card_id=target_card.id,
created_by=user_id,
)
db.session.add(link)
db.session.commit()
return jsonify(link.to_dict(include_cards=True)), 201
@kanban_bp.route("/cards/<int:card_id>/links/<int:link_id>", methods=["DELETE"])
@jwt_required()
def unlink_card(card_id, link_id):
"""Remove a link between cards."""
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
link = db.session.get(CardLink, link_id)
if not link:
return jsonify({"error": "Link not found"}), 404
# Verify the link involves this card
if link.parent_card_id != card.id and link.child_card_id != card.id:
return jsonify({"error": "Link does not belong to this card"}), 403
db.session.delete(link)
db.session.commit()
return jsonify({"message": "Cards unlinked successfully"}), 200
@kanban_bp.route("/cards/<int:card_id>/linked-cards", methods=["POST"])
@jwt_required()
def create_linked_card(card_id):
"""Create a new card linked to this card. Copies labels and epics."""
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
data = request.get_json()
name = data.get("name")
list_id = data.get("list_id")
description = data.get("description", "")
copy_labels = data.get("copy_labels", True)
copy_epics = data.get("copy_epics", True)
if not name:
return jsonify({"error": "name is required"}), 400
if not list_id:
return jsonify({"error": "list_id is required"}), 400
user_id = get_jwt_identity()
# Get the highest position in the target list
max_pos = (
db.session.query(db.func.max(Card.pos)).filter(Card.list_id == list_id).scalar()
)
pos = (max_pos or 0) + 65536
# Create the new card
new_card = Card(
name=name,
description=description,
board_id=card.board_id,
list_id=list_id,
pos=pos,
epic_id=card.epic_id if copy_epics else None,
)
db.session.add(new_card)
db.session.flush()
# Copy labels if requested
if copy_labels:
parent_labels = CardLabel.query.filter_by(card_id=card.id).all()
for pl in parent_labels:
new_label = CardLabel(card_id=new_card.id, label_id=pl.label_id)
db.session.add(new_label)
# Create link: parent card -> new child card
link = CardLink(
parent_card_id=card.id,
child_card_id=new_card.id,
created_by=user_id,
)
db.session.add(link)
db.session.commit()
return (
jsonify(
{
"card": new_card.to_dict(),
"link": link.to_dict(),
}
),
201,
)
@kanban_bp.route("/card-links/<int:link_id>", methods=["DELETE"])
@jwt_required()
def delete_card_link(link_id):
"""Remove a link by link ID."""
link = db.session.get(CardLink, link_id)
if not link:
return jsonify({"error": "Link not found"}), 404
db.session.delete(link)
db.session.commit()
return jsonify({"message": "Cards unlinked successfully"}), 200
@kanban_bp.route(
"/cards/<int:card_id>/checklist-items/<int:check_item_id>/convert-to-card",
methods=["POST"],
)
@jwt_required()
def convert_check_item_to_card(card_id, check_item_id):
"""Convert a checklist item to a new linked card."""
from app.models.check_item import CheckItem
card = db.session.get(Card, card_id)
if not card:
return jsonify({"error": "Card not found"}), 404
check_item = db.session.get(CheckItem, check_item_id)
if not check_item:
return jsonify({"error": "Check item not found"}), 404
data = request.get_json() or {}
list_id = data.get("list_id", card.list_id)
user_id = get_jwt_identity()
# Get the highest position in the target list
max_pos = (
db.session.query(db.func.max(Card.pos)).filter(Card.list_id == list_id).scalar()
)
pos = (max_pos or 0) + 65536
# Create the new card from checklist item name
new_card = Card(
name=check_item.name,
board_id=card.board_id,
list_id=list_id,
pos=pos,
epic_id=card.epic_id,
)
db.session.add(new_card)
db.session.flush()
# Copy labels from parent card
parent_labels = CardLabel.query.filter_by(card_id=card.id).all()
for pl in parent_labels:
new_label = CardLabel(card_id=new_card.id, label_id=pl.label_id)
db.session.add(new_label)
# Create link: parent card -> new child card
link = CardLink(
parent_card_id=card.id,
child_card_id=new_card.id,
created_by=user_id,
)
db.session.add(link)
# Remove the checklist item
db.session.delete(check_item)
db.session.commit()
return (
jsonify(
{
"card": new_card.to_dict(),
"link": link.to_dict(),
}
),
201,
)

View file

@ -18,11 +18,14 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Drop order_items first (has FK to products)
op.drop_table('order_items')
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_products_name'))
op.drop_table('products')
op.drop_table('order_items')
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_orders_status'))

View file

@ -0,0 +1,49 @@
"""add_card_links_table
Revision ID: bf430156bcf2
Revises: a9709e7ed22d
Create Date: 2026-04-30 19:37:25.884514
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bf430156bcf2'
down_revision = 'a9709e7ed22d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('card_links',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('parent_card_id', sa.Integer(), nullable=False),
sa.Column('child_card_id', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['child_card_id'], ['cards.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['parent_card_id'], ['cards.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('parent_card_id', 'child_card_id', name='unique_card_link')
)
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_card_links_child_card_id'), ['child_card_id'], unique=False)
batch_op.create_index(batch_op.f('ix_card_links_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_card_links_parent_card_id'), ['parent_card_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_card_links_parent_card_id'))
batch_op.drop_index(batch_op.f('ix_card_links_created_by'))
batch_op.drop_index(batch_op.f('ix_card_links_child_card_id'))
op.drop_table('card_links')
# ### end Alembic commands ###

View file

@ -1,13 +1,23 @@
import { useState, useRef, useEffect } from 'react';
import Edit2Icon from './icons/Edit2Icon';
import Trash2Icon from './icons/Trash2Icon';
import PlusIcon from './icons/PlusIcon';
import LinkIcon from './icons/LinkIcon';
import VerticalEllipsisIcon from './icons/VerticalEllipsisIcon';
interface CardActionDropdownProps {
onEdit: () => void;
onDelete: () => void;
onCreateLinkedCard?: () => void;
onLinkExistingCard?: () => void;
}
export function CardActionDropdown({ onEdit, onDelete }: CardActionDropdownProps) {
export function CardActionDropdown({
onEdit,
onDelete,
onCreateLinkedCard,
onLinkExistingCard,
}: CardActionDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -43,25 +53,11 @@ export function CardActionDropdown({ onEdit, onDelete }: CardActionDropdownProps
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>
<VerticalEllipsisIcon />
</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="absolute top-full right-0 mt-2 w-56 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50">
<div className="py-1">
<button
onClick={handleEdit}
@ -72,6 +68,34 @@ export function CardActionDropdown({ onEdit, onDelete }: CardActionDropdownProps
</span>
Edit Card
</button>
{onCreateLinkedCard && (
<button
onClick={() => {
setIsOpen(false);
onCreateLinkedCard();
}}
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">
<LinkIcon />
</span>
Create Linked Card
</button>
)}
{onLinkExistingCard && (
<button
onClick={() => {
setIsOpen(false);
onLinkExistingCard();
}}
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">
<PlusIcon />
</span>
Link Existing 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"

View file

@ -2,6 +2,7 @@ import CheckSquareIcon from './icons/CheckSquareIcon';
import Trash2Icon from './icons/Trash2Icon';
import Edit2Icon from './icons/Edit2Icon';
import PlusIcon from './icons/PlusIcon';
import MonitorIcon from './icons/MonitorIcon';
import { useModal } from '../context/modals/useModal';
import { CreateChecklistModal } from './CreateChecklistModal';
import { DeleteChecklistModal } from './DeleteChecklistModal';
@ -27,6 +28,7 @@ interface CardChecklistsProps {
state: 'incomplete' | 'complete'
) => Promise<boolean>;
removeCheckItem: (itemId: number) => Promise<boolean>;
onConvertToCard?: (itemName: string, itemId: number) => void;
}
export function CardChecklists({
@ -37,6 +39,7 @@ export function CardChecklists({
toggleCheckItem,
editCheckItem,
removeCheckItem,
onConvertToCard,
}: CardChecklistsProps) {
const { openModal } = useModal();
@ -152,7 +155,7 @@ export function CardChecklists({
</div>
<div className="space-y-2">
{checklist.items && checklist.items.length > 0 ? (
checklist.items.map((item: any) => (
checklist.items.map((item: any, itemIndex: number) => (
<div
key={item.id}
className="flex items-center gap-3 p-2 bg-gray-700 rounded group hover:bg-gray-600 transition-colors"
@ -167,9 +170,62 @@ export function CardChecklists({
onClick={() => handleToggleCheckItem(item)}
className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
>
<span className="text-gray-500 mr-1">{itemIndex + 1}.</span>
{item.name}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onConvertToCard && (
<button
onClick={() => {
openModal((modalProps) => (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<div className="flex items-center gap-3 mb-4">
<span className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center">
<span className="w-5 h-5">
<MonitorIcon />
</span>
</span>
<h3 className="text-xl font-bold text-white">
Convert to Card
</h3>
</div>
<p className="text-gray-300 mb-6">
Convert
<span className="text-white font-semibold">
{' '}
&quot;{item.name}&quot;{' '}
</span>
into a new linked card? The checklist item will be removed and
a new card will be created.
</p>
<div className="flex justify-end gap-3">
<button
onClick={modalProps.onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={() => {
modalProps.onClose();
onConvertToCard(item.name, item.id);
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Convert to Card
</button>
</div>
</div>
));
}}
className="text-gray-400 hover:text-blue-400 transition-colors p-1"
title="Convert to card"
>
<span className="w-3.5 h-3.5">
<MonitorIcon />
</span>
</button>
)}
<button
onClick={() => handleEditCheckItem(item)}
className="text-gray-400 hover:text-white transition-colors p-1"

View file

@ -0,0 +1,100 @@
import { Link } from 'react-router-dom';
import type { CardLinksResponse } from '../types/kanban';
import LinkIcon from './icons/LinkIcon';
import UnlinkIcon from './icons/UnlinkIcon';
interface CardLinksProps {
links: CardLinksResponse;
boardId: number;
onUnlink: (linkId: number) => void;
}
export function CardLinks({ links, boardId, onUnlink }: CardLinksProps) {
const { parent_cards: parentCards, child_cards: childCards } = links;
const hasLinks = parentCards.length > 0 || childCards.length > 0;
if (!hasLinks) {
return null;
}
return (
<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">
<LinkIcon />
</span>
Linked Cards
</h2>
{parentCards.length > 0 && (
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-400 mb-2 uppercase tracking-wide">
Parent Cards
</h3>
<div className="space-y-2">
{parentCards.map((link) => (
<div
key={link.id}
className="flex items-center justify-between bg-gray-750 border border-gray-700 rounded-lg px-3 py-2 hover:bg-gray-700 transition-colors"
>
<Link
to={`/boards/${boardId}/cards/${link.card.id}`}
className="flex-1 text-gray-200 hover:text-blue-400 transition-colors truncate"
>
<span className="text-gray-500 text-xs mr-2">
#{link.card.id_short || link.card.id}
</span>
{link.card.name}
</Link>
<button
onClick={() => onUnlink(link.id)}
className="text-gray-500 hover:text-red-400 transition-colors ml-2 p-1"
title="Unlink card"
>
<span className="w-4 h-4">
<UnlinkIcon />
</span>
</button>
</div>
))}
</div>
</div>
)}
{childCards.length > 0 && (
<div>
<h3 className="text-sm font-medium text-gray-400 mb-2 uppercase tracking-wide">
Child Cards
</h3>
<div className="space-y-2">
{childCards.map((link) => (
<div
key={link.id}
className="flex items-center justify-between bg-gray-750 border border-gray-700 rounded-lg px-3 py-2 hover:bg-gray-700 transition-colors"
>
<Link
to={`/boards/${boardId}/cards/${link.card.id}`}
className="flex-1 text-gray-200 hover:text-blue-400 transition-colors truncate"
>
<span className="text-gray-500 text-xs mr-2">
#{link.card.id_short || link.card.id}
</span>
{link.card.name}
</Link>
<button
onClick={() => onUnlink(link.id)}
className="text-gray-500 hover:text-red-400 transition-colors ml-2 p-1"
title="Unlink card"
>
<span className="w-4 h-4">
<UnlinkIcon />
</span>
</button>
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,94 @@
import { useState } from 'react';
import CloseIcon from './icons/CloseIcon';
interface CreateLinkedCardModalProps {
parentCardName: string;
onClose: () => void;
onSubmit: (name: string, description: string) => void;
}
export function CreateLinkedCardModal({
parentCardName,
onClose,
onSubmit,
}: CreateLinkedCardModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setIsSubmitting(true);
try {
await onSubmit(name.trim(), description.trim());
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4 border border-gray-700">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white">Create Linked Card</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<span className="w-5 h-5">
<CloseIcon />
</span>
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
This card will be linked as a child of{' '}
<span className="text-white font-medium">{parentCardName}</span>
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Card Name <span className="text-red-400">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter card name..."
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y min-h-[80px]"
placeholder="Add a description..."
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || isSubmitting}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{isSubmitting ? 'Creating...' : 'Create & Link'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import CloseIcon from './icons/CloseIcon';
import { useApi } from '../hooks/useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Card } from '../types/kanban';
interface LinkExistingCardModalProps {
boardId: number;
currentCardId: number;
onClose: () => void;
onLinked: () => void;
}
export function LinkExistingCardModal({
boardId,
currentCardId,
onClose,
onLinked,
}: LinkExistingCardModalProps) {
const [search, setSearch] = useState('');
const [cards, setCards] = useState<Card[]>([]);
const [selectedCardId, setSelectedCardId] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { getBoard, createCardLink } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
useEffect(() => {
const fetchCards = async () => {
try {
const board = await getBoard(boardId);
const allCards: Card[] = [];
for (const list of board.lists) {
allCards.push(...list.cards);
}
// Filter out the current card
setCards(allCards.filter((c) => c.id !== currentCardId));
} catch (err) {
console.error(err);
addNotification({
type: 'error',
title: 'Error',
message: 'Failed to load board cards',
duration: 5000,
});
}
};
fetchCards();
}, [getBoard, boardId, currentCardId, addNotification]);
const filteredCards = cards.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
String(c.id_short || c.id).includes(search)
);
const handleLink = async () => {
if (!selectedCardId) return;
setIsSubmitting(true);
try {
await withLoader(() => createCardLink(currentCardId, selectedCardId), 'Linking card...');
addNotification({
type: 'success',
title: 'Card Linked',
message: 'Card linked successfully.',
duration: 3000,
});
onLinked();
onClose();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to link card';
addNotification({
type: 'error',
title: 'Error',
message: msg,
duration: 5000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4 border border-gray-700">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white">Link Existing Card</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<span className="w-5 h-5">
<CloseIcon />
</span>
</button>
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 mb-3"
placeholder="Search cards by name or ID..."
autoFocus
/>
<div className="max-h-60 overflow-y-auto space-y-1 scrollbar-custom">
{filteredCards.length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">No cards found</p>
)}
{filteredCards.map((c) => (
<button
key={c.id}
onClick={() => setSelectedCardId(c.id)}
className={`w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-2 ${
selectedCardId === c.id
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700'
}`}
>
<span className="text-gray-500 text-xs">#{c.id_short || c.id}</span>
<span className="truncate">{c.name}</span>
<span className="ml-auto text-xs text-gray-500">{c.list_name}</span>
</button>
))}
</div>
<div className="flex justify-end gap-3 pt-4 mt-2 border-t border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleLink}
disabled={!selectedCardId || isSubmitting}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{isSubmitting ? 'Linking...' : 'Link Card'}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
const ChevronLeftIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15 18l-6-6 6-6" />
</svg>
);
export default ChevronLeftIcon;

View file

@ -0,0 +1,18 @@
const LinkIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
);
export default LinkIcon;

View file

@ -0,0 +1,19 @@
const MonitorIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
);
export default MonitorIcon;

View file

@ -0,0 +1,19 @@
const VerticalEllipsisIcon = () => (
<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>
);
export default VerticalEllipsisIcon;

View file

@ -3,6 +3,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Card as CardType } from '../../types/kanban';
import MessageSquareIcon from '../icons/MessageSquareIcon';
import CheckSquareIcon from '../icons/CheckSquareIcon';
import ChevronLeftIcon from '../icons/ChevronLeftIcon';
import { Link } from 'react-router-dom';
interface KanbanCardProps {
@ -47,6 +48,10 @@ export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
const epic = (card as any).epic;
const hasEpic = epic !== null && epic !== undefined;
// Get parent card name
const parentCardName = card.parent_card_name;
const hasParent = !!parentCardName;
return (
<div
ref={setNodeRef}
@ -61,6 +66,18 @@ export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
}}
className="bg-gray-700 rounded-lg p-4 mb-3 cursor-pointer hover:bg-gray-600 transition-colors border border-gray-600 shadow-sm"
>
{/* Parent Card Indicator */}
{hasParent && (
<div className="mb-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-purple-900/40 text-purple-300 border border-purple-700/50">
<span className="w-2.5 h-2.5">
<ChevronLeftIcon />
</span>
{parentCardName}
</span>
</div>
)}
{/* Epic Badge */}
{hasEpic && (
<div className="mb-2">

View file

@ -291,6 +291,34 @@ export function useApi() {
await api.delete(`/cards/${cardId}/epics/${epicId}`);
},
// Card Links
getCardLinks: async (cardId: number): Promise<any> => {
const response = await api.get(`/cards/${cardId}/links`);
return response.data;
},
createCardLink: async (parentCardId: number, childCardId: number): Promise<any> => {
const response = await api.post(`/cards/${parentCardId}/links`, {
child_card_id: childCardId,
});
return response.data;
},
createLinkedCard: async (
parentCardId: number,
data: {
name: string;
list_id: number;
description?: string;
copy_labels?: boolean;
copy_epics?: boolean;
}
): Promise<any> => {
const response = await api.post(`/cards/${parentCardId}/linked-cards`, data);
return response.data;
},
deleteCardLink: async (linkId: number): Promise<void> => {
await api.delete(`/card-links/${linkId}`);
},
// Wikis
getWikis: async (boardId: number): Promise<any> => {
const response = await api.get(`/boards/${boardId}/wikis`);

View file

@ -3,7 +3,8 @@ import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
export function useCardDetailMutations(cardId: number, currentCard: any, onUpdate: () => void) {
const { updateCard, deleteCard, createComment, updateComment, deleteComment } = useApi();
const { updateCard, deleteCard, createComment, updateComment, deleteComment, createLinkedCard } =
useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
@ -127,11 +128,87 @@ export function useCardDetailMutations(cardId: number, currentCard: any, onUpdat
}
};
const createLinkedCardFromModal = async (
name: string,
description: string,
listId: number,
refreshLinks: () => Promise<void>
) => {
try {
await withLoader(
() =>
createLinkedCard(cardId, {
name,
list_id: listId,
description,
copy_labels: true,
copy_epics: true,
}),
'Creating linked card...'
);
await refreshLinks();
addNotification({
type: 'success',
title: 'Linked Card Created',
message: `"${name}" created and linked.`,
duration: 3000,
});
return true;
} catch (err) {
console.error(err);
addNotification({
type: 'error',
title: 'Error',
message: 'Failed to create linked card.',
duration: 5000,
});
return false;
}
};
const convertCheckItemToCard = async (
itemName: string,
itemId: number,
listId: number,
removeCheckItem: (itemId: number) => Promise<boolean>,
refreshLinks: () => Promise<void>
) => {
try {
await withLoader(
() =>
createLinkedCard(cardId, {
name: itemName,
list_id: listId,
}),
'Converting to card...'
);
await removeCheckItem(itemId);
onUpdate();
await refreshLinks();
addNotification({
type: 'success',
title: 'Converted to Card',
message: `"${itemName}" is now a linked card.`,
duration: 3000,
});
} catch (err) {
console.error(err);
addNotification({
type: 'error',
title: 'Error',
message: 'Failed to convert item to card.',
duration: 5000,
});
}
};
return {
updateCardNameAndDescription,
deleteCardWithConfirmation,
addComment,
editComment,
deleteCommentWithConfirmation,
createLinkedCardFromModal,
convertCheckItemToCard,
};
}

View file

@ -0,0 +1,105 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { CardLinksResponse } from '../types/kanban';
export function useCardLinks(cardId: number) {
const [links, setLinks] = useState<CardLinksResponse>({
child_cards: [],
parent_cards: [],
});
const [error, setError] = useState<Error | null>(null);
const { getCardLinks, createCardLink, deleteCardLink } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchLinks = useCallback(async () => {
try {
setError(null);
const data = await getCardLinks(cardId);
setLinks(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load card links';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Links',
message: errorMessage,
duration: 5000,
});
return { child_cards: [], parent_cards: [] };
}
}, [getCardLinks, cardId, addNotification]);
const linkCard = useCallback(
async (childCardId: number) => {
try {
setError(null);
await withLoader(() => createCardLink(cardId, childCardId), 'Linking card...');
addNotification({
type: 'success',
title: 'Card Linked',
message: 'Card linked successfully.',
duration: 3000,
});
await fetchLinks();
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to link card';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Linking Card',
message: errorMessage,
duration: 5000,
});
return false;
}
},
[cardId, createCardLink, withLoader, addNotification, fetchLinks]
);
const unlinkCard = useCallback(
async (linkId: number) => {
try {
setError(null);
await withLoader(() => deleteCardLink(linkId), 'Unlinking card...');
addNotification({
type: 'success',
title: 'Card Unlinked',
message: 'Card unlinked successfully.',
duration: 3000,
});
await fetchLinks();
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to unlink card';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Unlinking Card',
message: errorMessage,
duration: 5000,
});
return false;
}
},
[deleteCardLink, withLoader, addNotification, fetchLinks]
);
useEffect(() => {
fetchLinks();
}, [fetchLinks]);
return {
links,
error,
loading: false,
fetchLinks,
linkCard,
unlinkCard,
};
}

View file

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCard } from '../hooks/useCard';
import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
@ -6,14 +7,18 @@ import { useLabels } from '../hooks/useLabels';
import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations';
import { useModal } from '../context/modals/useModal';
import { useInlineEditing } from '../hooks/useInlineEditing';
import { useCardLinks } from '../hooks/useCardLinks';
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 { CardLinks } from '../components/CardLinks';
import { DeleteCardModal } from '../components/DeleteCardModal';
import { CardActionDropdown } from '../components/CardActionDropdown';
import { CreateLinkedCardModal } from '../components/CreateLinkedCardModal';
import { LinkExistingCardModal } from '../components/LinkExistingCardModal';
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
import { NarrowPageLayout } from '@/components/NarrowPageLayout';
import { formatDateTime } from '../utils/dateFormat';
@ -29,11 +34,19 @@ export function CardDetail() {
addComment,
editComment,
deleteCommentWithConfirmation,
createLinkedCardFromModal,
convertCheckItemToCard,
} = useCardDetailMutations(parseInt(cardId || '0'), card, fetchCard);
const { openModal } = useModal();
const checklistMutations = useChecklistMutations(parseInt(cardId || '0'), fetchCard);
// Card links
const cardLinksHook = useCardLinks(parseInt(cardId || '0'));
const [showCreateLinkedModal, setShowCreateLinkedModal] = useState(false);
const [showLinkExistingModal, setShowLinkExistingModal] = useState(false);
// Inline editing hook
const inlineEditing = useInlineEditing(card, updateCardNameAndDescription);
@ -126,6 +139,8 @@ export function CardDetail() {
<CardActionDropdown
onEdit={inlineEditing.handleStartEditingName}
onDelete={handleDeleteCard}
onCreateLinkedCard={() => setShowCreateLinkedModal(true)}
onLinkExistingCard={() => setShowLinkExistingModal(true)}
/>
</div>
@ -195,6 +210,13 @@ export function CardDetail() {
refetchCard={fetchCard}
/>
{/* Linked Cards Section */}
<CardLinks
links={cardLinksHook.links}
boardId={parseInt(boardId || '0')}
onUnlink={cardLinksHook.unlinkCard}
/>
{/* Checklists Section */}
<CardChecklists
checklists={card.checklists || []}
@ -205,6 +227,15 @@ export function CardDetail() {
toggleCheckItem={checklistMutations.toggleCheckItem}
editCheckItem={checklistMutations.editCheckItem}
removeCheckItem={checklistMutations.removeCheckItem}
onConvertToCard={(itemName: string, itemId: number) =>
convertCheckItemToCard(
itemName,
itemId,
card.list_id,
checklistMutations.removeCheckItem,
cardLinksHook.fetchLinks
)
}
/>
<CardAttachments cardId={cardId ? parseInt(cardId) : undefined} />
@ -220,6 +251,35 @@ export function CardDetail() {
<CardSidebar card={card} />
</div>
{/* Create Linked Card Modal */}
{showCreateLinkedModal && card && (
<CreateLinkedCardModal
parentCardName={card.name}
onClose={() => setShowCreateLinkedModal(false)}
onSubmit={async (name: string, description: string) => {
const success = await createLinkedCardFromModal(
name,
description,
card.list_id,
cardLinksHook.fetchLinks
);
if (success) {
setShowCreateLinkedModal(false);
}
}}
/>
)}
{/* Link Existing Card Modal */}
{showLinkExistingModal && (
<LinkExistingCardModal
boardId={parseInt(boardId || '0')}
currentCardId={parseInt(cardId || '0')}
onClose={() => setShowLinkExistingModal(false)}
onLinked={() => cardLinksHook.fetchLinks()}
/>
)}
</NarrowPageLayout>
);
}

View file

@ -81,6 +81,7 @@ export interface Card {
badges: Record<string, any>;
cover: Record<string, any>;
desc_data: Record<string, any>;
parent_card_name: string | null;
}
export interface CardWithDetails extends Card {
@ -193,6 +194,24 @@ export interface User {
last_name?: string;
}
// CardLink types
export interface CardLink {
id: number;
parent_card_id: number;
child_card_id: number;
created_by: number;
created_at: string;
}
export interface CardLinkWithCard extends CardLink {
card: Card;
}
export interface CardLinksResponse {
child_cards: CardLinkWithCard[];
parent_cards: CardLinkWithCard[];
}
// File Attachment types
export interface FileAttachment {
uuid: string;