diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d1fce73..5fc864c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 diff --git a/backend/app/models/card.py b/backend/app/models/card.py index 1e3feab..0949ae1 100644 --- a/backend/app/models/card.py +++ b/backend/app/models/card.py @@ -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"" @@ -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) diff --git a/backend/app/models/card_link.py b/backend/app/models/card_link.py new file mode 100644 index 0000000..49850cc --- /dev/null +++ b/backend/app/models/card_link.py @@ -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" {self.child_card_id}>" + + __table_args__ = ( + db.UniqueConstraint("parent_card_id", "child_card_id", name="unique_card_link"), + ) diff --git a/backend/app/routes/kanban/__init__.py b/backend/app/routes/kanban/__init__.py index a9fe728..ccfb59f 100644 --- a/backend/app/routes/kanban/__init__.py +++ b/backend/app/routes/kanban/__init__.py @@ -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 diff --git a/backend/app/routes/kanban/card_links.py b/backend/app/routes/kanban/card_links.py new file mode 100644 index 0000000..fe13395 --- /dev/null +++ b/backend/app/routes/kanban/card_links.py @@ -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//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//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//links/", 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//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/", 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//checklist-items//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, + ) diff --git a/backend/migrations/versions/a9709e7ed22d_remove_products_tables.py b/backend/migrations/versions/a9709e7ed22d_remove_products_tables.py index 7224353..3360027 100644 --- a/backend/migrations/versions/a9709e7ed22d_remove_products_tables.py +++ b/backend/migrations/versions/a9709e7ed22d_remove_products_tables.py @@ -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')) diff --git a/backend/migrations/versions/bf430156bcf2_add_card_links_table.py b/backend/migrations/versions/bf430156bcf2_add_card_links_table.py new file mode 100644 index 0000000..c31ede0 --- /dev/null +++ b/backend/migrations/versions/bf430156bcf2_add_card_links_table.py @@ -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 ### diff --git a/frontend/src/components/CardActionDropdown.tsx b/frontend/src/components/CardActionDropdown.tsx index 6fd9792..e975314 100644 --- a/frontend/src/components/CardActionDropdown.tsx +++ b/frontend/src/components/CardActionDropdown.tsx @@ -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(null); @@ -43,25 +53,11 @@ export function CardActionDropdown({ onEdit, onDelete }: CardActionDropdownProps aria-label="Card actions" aria-expanded={isOpen} > - - - - - + {isOpen && ( -
+
+ {onCreateLinkedCard && ( + + )} + {onLinkExistingCard && ( + + )}
{checklist.items && checklist.items.length > 0 ? ( - checklist.items.map((item: any) => ( + checklist.items.map((item: any, itemIndex: number) => (
handleToggleCheckItem(item)} className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`} > + {itemIndex + 1}. {item.name}
+ {onConvertToCard && ( + + +
+
+ )); + }} + className="text-gray-400 hover:text-blue-400 transition-colors p-1" + title="Convert to card" + > + + + + + )} +
+ ))} +
+
+ )} + + {childCards.length > 0 && ( +
+

+ Child Cards +

+
+ {childCards.map((link) => ( +
+ + + #{link.card.id_short || link.card.id} + + {link.card.name} + + +
+ ))} +
+
+ )} + + ); +} diff --git a/frontend/src/components/CreateLinkedCardModal.tsx b/frontend/src/components/CreateLinkedCardModal.tsx new file mode 100644 index 0000000..ffb5d0f --- /dev/null +++ b/frontend/src/components/CreateLinkedCardModal.tsx @@ -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 ( +
+
+
+
+

Create Linked Card

+ +
+ +

+ This card will be linked as a child of{' '} + {parentCardName} +

+ +
+
+ + 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 + /> +
+ +
+ +