add card hierarchy, adding linked card and converting checklist item to card
This commit is contained in:
parent
f59670397a
commit
543c28984f
22 changed files with 1262 additions and 41 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
|
# fmt: off
|
||||||
from app.models.board import Board
|
from app.models.board import Board
|
||||||
from app.models.card import Card
|
from app.models.card import Card
|
||||||
from app.models.card_label import CardLabel
|
from app.models.card_label import CardLabel
|
||||||
|
from app.models.card_link import CardLink
|
||||||
from app.models.check_item import CheckItem
|
from app.models.check_item import CheckItem
|
||||||
from app.models.checklist import Checklist
|
from app.models.checklist import Checklist
|
||||||
from app.models.comment import Comment
|
from app.models.comment import Comment
|
||||||
|
|
@ -18,6 +20,7 @@ __all__ = [
|
||||||
"Card",
|
"Card",
|
||||||
"Label",
|
"Label",
|
||||||
"CardLabel",
|
"CardLabel",
|
||||||
|
"CardLink",
|
||||||
"Checklist",
|
"Checklist",
|
||||||
"CheckItem",
|
"CheckItem",
|
||||||
"Comment",
|
"Comment",
|
||||||
|
|
@ -26,3 +29,4 @@ __all__ = [
|
||||||
"Wiki",
|
"Wiki",
|
||||||
"wiki_entity_links",
|
"wiki_entity_links",
|
||||||
]
|
]
|
||||||
|
# fmt: on
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,25 @@ class Card(db.Model):
|
||||||
lazy="dynamic",
|
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"""
|
"""Convert card to dictionary"""
|
||||||
return {
|
result = {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
|
|
@ -82,7 +98,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,
|
"list_name": self.list.name if self.list else None,
|
||||||
"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
|
||||||
|
|
@ -92,7 +108,24 @@ class Card(db.Model):
|
||||||
"badges": self.badges,
|
"badges": self.badges,
|
||||||
"cover": self.cover,
|
"cover": self.cover,
|
||||||
"desc_data": self.desc_data,
|
"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):
|
def __repr__(self):
|
||||||
return f"<Card {self.name}>"
|
return f"<Card {self.name}>"
|
||||||
|
|
@ -106,19 +139,16 @@ def update_epic_metrics_on_card_change(mapper, connection, target):
|
||||||
|
|
||||||
from app.models import Epic
|
from app.models import Epic
|
||||||
|
|
||||||
# Get total card count
|
|
||||||
card_count_stmt = select(db.func.count(Card.id)).where(
|
card_count_stmt = select(db.func.count(Card.id)).where(
|
||||||
Card.epic_id == target.epic_id
|
Card.epic_id == target.epic_id
|
||||||
)
|
)
|
||||||
card_count = connection.execute(card_count_stmt).scalar()
|
card_count = connection.execute(card_count_stmt).scalar()
|
||||||
|
|
||||||
# Get epic's completed_list_id
|
|
||||||
completed_list_id_stmt = select(Epic.completed_list_id).where(
|
completed_list_id_stmt = select(Epic.completed_list_id).where(
|
||||||
Epic.id == target.epic_id
|
Epic.id == target.epic_id
|
||||||
)
|
)
|
||||||
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
|
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
|
completed_cards_count = 0
|
||||||
if completed_list_id:
|
if completed_list_id:
|
||||||
completed_cards_stmt = select(db.func.count(Card.id)).where(
|
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()
|
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
|
||||||
|
|
||||||
# Update epic metrics
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
update(Epic)
|
update(Epic)
|
||||||
.where(Epic.id == target.epic_id)
|
.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
|
from app.models import Epic
|
||||||
|
|
||||||
# Get total card count
|
|
||||||
card_count_stmt = select(db.func.count(Card.id)).where(
|
card_count_stmt = select(db.func.count(Card.id)).where(
|
||||||
Card.epic_id == target.epic_id
|
Card.epic_id == target.epic_id
|
||||||
)
|
)
|
||||||
card_count = connection.execute(card_count_stmt).scalar()
|
card_count = connection.execute(card_count_stmt).scalar()
|
||||||
|
|
||||||
# Get epic's completed_list_id
|
|
||||||
completed_list_id_stmt = select(Epic.completed_list_id).where(
|
completed_list_id_stmt = select(Epic.completed_list_id).where(
|
||||||
Epic.id == target.epic_id
|
Epic.id == target.epic_id
|
||||||
)
|
)
|
||||||
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
|
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
|
completed_cards_count = 0
|
||||||
if completed_list_id:
|
if completed_list_id:
|
||||||
completed_cards_stmt = select(db.func.count(Card.id)).where(
|
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()
|
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
|
||||||
|
|
||||||
# Update epic metrics
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
update(Epic)
|
update(Epic)
|
||||||
.where(Epic.id == target.epic_id)
|
.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
|
from app.models import Epic
|
||||||
|
|
||||||
# Get total card count
|
|
||||||
card_count_stmt = select(db.func.count(Card.id)).where(
|
card_count_stmt = select(db.func.count(Card.id)).where(
|
||||||
Card.epic_id == target.epic_id
|
Card.epic_id == target.epic_id
|
||||||
)
|
)
|
||||||
card_count = connection.execute(card_count_stmt).scalar()
|
card_count = connection.execute(card_count_stmt).scalar()
|
||||||
|
|
||||||
# Get epic's completed_list_id
|
|
||||||
completed_list_id_stmt = select(Epic.completed_list_id).where(
|
completed_list_id_stmt = select(Epic.completed_list_id).where(
|
||||||
Epic.id == target.epic_id
|
Epic.id == target.epic_id
|
||||||
)
|
)
|
||||||
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
|
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
|
completed_cards_count = 0
|
||||||
if completed_list_id:
|
if completed_list_id:
|
||||||
completed_cards_stmt = select(db.func.count(Card.id)).where(
|
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()
|
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
|
||||||
|
|
||||||
# Update epic metrics
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
update(Epic)
|
update(Epic)
|
||||||
.where(Epic.id == target.epic_id)
|
.where(Epic.id == target.epic_id)
|
||||||
|
|
|
||||||
66
backend/app/models/card_link.py
Normal file
66
backend/app/models/card_link.py
Normal 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"),
|
||||||
|
)
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
|
# fmt: off
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
# Create the kanban blueprint that will be used by all route modules
|
# Create the kanban blueprint that will be used by all route modules
|
||||||
kanban_bp = Blueprint("kanban", __name__)
|
kanban_bp = Blueprint("kanban", __name__)
|
||||||
|
|
||||||
# Import all route modules to register their routes to this blueprint
|
# Import all route modules to register their routes to this blueprint
|
||||||
|
from . import (boards, card_links, cards, checklists, # noqa: F401 E402
|
||||||
# fmt: off
|
comments, epics, files, labels, lists, wikis)
|
||||||
from . import (boards, cards, checklists, comments, epics, # noqa: F401 E402
|
|
||||||
files, labels, lists, wikis)
|
|
||||||
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
__all__ = ["kanban_bp"]
|
__all__ = ["kanban_bp"]
|
||||||
|
# fmt: on
|
||||||
|
|
|
||||||
279
backend/app/routes/kanban/card_links.py
Normal file
279
backend/app/routes/kanban/card_links.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -18,11 +18,14 @@ depends_on = None
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### 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:
|
with op.batch_alter_table('products', schema=None) as batch_op:
|
||||||
batch_op.drop_index(batch_op.f('ix_products_name'))
|
batch_op.drop_index(batch_op.f('ix_products_name'))
|
||||||
|
|
||||||
op.drop_table('products')
|
op.drop_table('products')
|
||||||
op.drop_table('order_items')
|
|
||||||
with op.batch_alter_table('orders', schema=None) as batch_op:
|
with op.batch_alter_table('orders', schema=None) as batch_op:
|
||||||
batch_op.drop_index(batch_op.f('ix_orders_status'))
|
batch_op.drop_index(batch_op.f('ix_orders_status'))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ###
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import Edit2Icon from './icons/Edit2Icon';
|
import Edit2Icon from './icons/Edit2Icon';
|
||||||
import Trash2Icon from './icons/Trash2Icon';
|
import Trash2Icon from './icons/Trash2Icon';
|
||||||
|
import PlusIcon from './icons/PlusIcon';
|
||||||
|
import LinkIcon from './icons/LinkIcon';
|
||||||
|
import VerticalEllipsisIcon from './icons/VerticalEllipsisIcon';
|
||||||
|
|
||||||
interface CardActionDropdownProps {
|
interface CardActionDropdownProps {
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => 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 [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -43,25 +53,11 @@ export function CardActionDropdown({ onEdit, onDelete }: CardActionDropdownProps
|
||||||
aria-label="Card actions"
|
aria-label="Card actions"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
<svg
|
<VerticalEllipsisIcon />
|
||||||
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>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{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">
|
<div className="py-1">
|
||||||
<button
|
<button
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
|
|
@ -72,6 +68,34 @@ export function CardActionDropdown({ onEdit, onDelete }: CardActionDropdownProps
|
||||||
</span>
|
</span>
|
||||||
Edit Card
|
Edit Card
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleDelete}
|
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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import CheckSquareIcon from './icons/CheckSquareIcon';
|
||||||
import Trash2Icon from './icons/Trash2Icon';
|
import Trash2Icon from './icons/Trash2Icon';
|
||||||
import Edit2Icon from './icons/Edit2Icon';
|
import Edit2Icon from './icons/Edit2Icon';
|
||||||
import PlusIcon from './icons/PlusIcon';
|
import PlusIcon from './icons/PlusIcon';
|
||||||
|
import MonitorIcon from './icons/MonitorIcon';
|
||||||
import { useModal } from '../context/modals/useModal';
|
import { useModal } from '../context/modals/useModal';
|
||||||
import { CreateChecklistModal } from './CreateChecklistModal';
|
import { CreateChecklistModal } from './CreateChecklistModal';
|
||||||
import { DeleteChecklistModal } from './DeleteChecklistModal';
|
import { DeleteChecklistModal } from './DeleteChecklistModal';
|
||||||
|
|
@ -27,6 +28,7 @@ interface CardChecklistsProps {
|
||||||
state: 'incomplete' | 'complete'
|
state: 'incomplete' | 'complete'
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
removeCheckItem: (itemId: number) => Promise<boolean>;
|
removeCheckItem: (itemId: number) => Promise<boolean>;
|
||||||
|
onConvertToCard?: (itemName: string, itemId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardChecklists({
|
export function CardChecklists({
|
||||||
|
|
@ -37,6 +39,7 @@ export function CardChecklists({
|
||||||
toggleCheckItem,
|
toggleCheckItem,
|
||||||
editCheckItem,
|
editCheckItem,
|
||||||
removeCheckItem,
|
removeCheckItem,
|
||||||
|
onConvertToCard,
|
||||||
}: CardChecklistsProps) {
|
}: CardChecklistsProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
|
|
@ -152,7 +155,7 @@ export function CardChecklists({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{checklist.items && checklist.items.length > 0 ? (
|
{checklist.items && checklist.items.length > 0 ? (
|
||||||
checklist.items.map((item: any) => (
|
checklist.items.map((item: any, itemIndex: number) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center gap-3 p-2 bg-gray-700 rounded group hover:bg-gray-600 transition-colors"
|
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)}
|
onClick={() => handleToggleCheckItem(item)}
|
||||||
className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
|
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}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<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">
|
||||||
|
{' '}
|
||||||
|
"{item.name}"{' '}
|
||||||
|
</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
|
<button
|
||||||
onClick={() => handleEditCheckItem(item)}
|
onClick={() => handleEditCheckItem(item)}
|
||||||
className="text-gray-400 hover:text-white transition-colors p-1"
|
className="text-gray-400 hover:text-white transition-colors p-1"
|
||||||
|
|
|
||||||
100
frontend/src/components/CardLinks.tsx
Normal file
100
frontend/src/components/CardLinks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
frontend/src/components/CreateLinkedCardModal.tsx
Normal file
94
frontend/src/components/CreateLinkedCardModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/src/components/LinkExistingCardModal.tsx
Normal file
148
frontend/src/components/LinkExistingCardModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/components/icons/ChevronLeftIcon.tsx
Normal file
17
frontend/src/components/icons/ChevronLeftIcon.tsx
Normal 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;
|
||||||
18
frontend/src/components/icons/LinkIcon.tsx
Normal file
18
frontend/src/components/icons/LinkIcon.tsx
Normal 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;
|
||||||
19
frontend/src/components/icons/MonitorIcon.tsx
Normal file
19
frontend/src/components/icons/MonitorIcon.tsx
Normal 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;
|
||||||
19
frontend/src/components/icons/VerticalEllipsisIcon.tsx
Normal file
19
frontend/src/components/icons/VerticalEllipsisIcon.tsx
Normal 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;
|
||||||
|
|
@ -3,6 +3,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Card as CardType } from '../../types/kanban';
|
import { Card as CardType } from '../../types/kanban';
|
||||||
import MessageSquareIcon from '../icons/MessageSquareIcon';
|
import MessageSquareIcon from '../icons/MessageSquareIcon';
|
||||||
import CheckSquareIcon from '../icons/CheckSquareIcon';
|
import CheckSquareIcon from '../icons/CheckSquareIcon';
|
||||||
|
import ChevronLeftIcon from '../icons/ChevronLeftIcon';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
interface KanbanCardProps {
|
interface KanbanCardProps {
|
||||||
|
|
@ -47,6 +48,10 @@ export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
|
||||||
const epic = (card as any).epic;
|
const epic = (card as any).epic;
|
||||||
const hasEpic = epic !== null && epic !== undefined;
|
const hasEpic = epic !== null && epic !== undefined;
|
||||||
|
|
||||||
|
// Get parent card name
|
||||||
|
const parentCardName = card.parent_card_name;
|
||||||
|
const hasParent = !!parentCardName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
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"
|
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 */}
|
{/* Epic Badge */}
|
||||||
{hasEpic && (
|
{hasEpic && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,34 @@ export function useApi() {
|
||||||
await api.delete(`/cards/${cardId}/epics/${epicId}`);
|
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
|
// Wikis
|
||||||
getWikis: async (boardId: number): Promise<any> => {
|
getWikis: async (boardId: number): Promise<any> => {
|
||||||
const response = await api.get(`/boards/${boardId}/wikis`);
|
const response = await api.get(`/boards/${boardId}/wikis`);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { useLoader } from '../context/loaders/useLoader';
|
||||||
import { useToast } from '../context/toasts/useToast';
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
|
||||||
export function useCardDetailMutations(cardId: number, currentCard: any, onUpdate: () => void) {
|
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 { withLoader } = useLoader();
|
||||||
const { addNotification } = useToast();
|
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 {
|
return {
|
||||||
updateCardNameAndDescription,
|
updateCardNameAndDescription,
|
||||||
deleteCardWithConfirmation,
|
deleteCardWithConfirmation,
|
||||||
addComment,
|
addComment,
|
||||||
editComment,
|
editComment,
|
||||||
deleteCommentWithConfirmation,
|
deleteCommentWithConfirmation,
|
||||||
|
createLinkedCardFromModal,
|
||||||
|
convertCheckItemToCard,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
frontend/src/hooks/useCardLinks.ts
Normal file
105
frontend/src/hooks/useCardLinks.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useCard } from '../hooks/useCard';
|
import { useCard } from '../hooks/useCard';
|
||||||
import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
|
import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
|
||||||
|
|
@ -6,14 +7,18 @@ 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 { useInlineEditing } from '../hooks/useInlineEditing';
|
||||||
|
import { useCardLinks } from '../hooks/useCardLinks';
|
||||||
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 { CardLinks } from '../components/CardLinks';
|
||||||
import { DeleteCardModal } from '../components/DeleteCardModal';
|
import { DeleteCardModal } from '../components/DeleteCardModal';
|
||||||
import { CardActionDropdown } from '../components/CardActionDropdown';
|
import { CardActionDropdown } from '../components/CardActionDropdown';
|
||||||
|
import { CreateLinkedCardModal } from '../components/CreateLinkedCardModal';
|
||||||
|
import { LinkExistingCardModal } from '../components/LinkExistingCardModal';
|
||||||
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
|
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
|
||||||
import { NarrowPageLayout } from '@/components/NarrowPageLayout';
|
import { NarrowPageLayout } from '@/components/NarrowPageLayout';
|
||||||
import { formatDateTime } from '../utils/dateFormat';
|
import { formatDateTime } from '../utils/dateFormat';
|
||||||
|
|
@ -29,11 +34,19 @@ export function CardDetail() {
|
||||||
addComment,
|
addComment,
|
||||||
editComment,
|
editComment,
|
||||||
deleteCommentWithConfirmation,
|
deleteCommentWithConfirmation,
|
||||||
|
createLinkedCardFromModal,
|
||||||
|
convertCheckItemToCard,
|
||||||
} = useCardDetailMutations(parseInt(cardId || '0'), card, fetchCard);
|
} = useCardDetailMutations(parseInt(cardId || '0'), card, fetchCard);
|
||||||
|
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const checklistMutations = useChecklistMutations(parseInt(cardId || '0'), fetchCard);
|
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
|
// Inline editing hook
|
||||||
const inlineEditing = useInlineEditing(card, updateCardNameAndDescription);
|
const inlineEditing = useInlineEditing(card, updateCardNameAndDescription);
|
||||||
|
|
||||||
|
|
@ -126,6 +139,8 @@ export function CardDetail() {
|
||||||
<CardActionDropdown
|
<CardActionDropdown
|
||||||
onEdit={inlineEditing.handleStartEditingName}
|
onEdit={inlineEditing.handleStartEditingName}
|
||||||
onDelete={handleDeleteCard}
|
onDelete={handleDeleteCard}
|
||||||
|
onCreateLinkedCard={() => setShowCreateLinkedModal(true)}
|
||||||
|
onLinkExistingCard={() => setShowLinkExistingModal(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -195,6 +210,13 @@ export function CardDetail() {
|
||||||
refetchCard={fetchCard}
|
refetchCard={fetchCard}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Linked Cards Section */}
|
||||||
|
<CardLinks
|
||||||
|
links={cardLinksHook.links}
|
||||||
|
boardId={parseInt(boardId || '0')}
|
||||||
|
onUnlink={cardLinksHook.unlinkCard}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Checklists Section */}
|
{/* Checklists Section */}
|
||||||
<CardChecklists
|
<CardChecklists
|
||||||
checklists={card.checklists || []}
|
checklists={card.checklists || []}
|
||||||
|
|
@ -205,6 +227,15 @@ export function CardDetail() {
|
||||||
toggleCheckItem={checklistMutations.toggleCheckItem}
|
toggleCheckItem={checklistMutations.toggleCheckItem}
|
||||||
editCheckItem={checklistMutations.editCheckItem}
|
editCheckItem={checklistMutations.editCheckItem}
|
||||||
removeCheckItem={checklistMutations.removeCheckItem}
|
removeCheckItem={checklistMutations.removeCheckItem}
|
||||||
|
onConvertToCard={(itemName: string, itemId: number) =>
|
||||||
|
convertCheckItemToCard(
|
||||||
|
itemName,
|
||||||
|
itemId,
|
||||||
|
card.list_id,
|
||||||
|
checklistMutations.removeCheckItem,
|
||||||
|
cardLinksHook.fetchLinks
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardAttachments cardId={cardId ? parseInt(cardId) : undefined} />
|
<CardAttachments cardId={cardId ? parseInt(cardId) : undefined} />
|
||||||
|
|
@ -220,6 +251,35 @@ export function CardDetail() {
|
||||||
|
|
||||||
<CardSidebar card={card} />
|
<CardSidebar card={card} />
|
||||||
</div>
|
</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>
|
</NarrowPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ export interface Card {
|
||||||
badges: Record<string, any>;
|
badges: Record<string, any>;
|
||||||
cover: Record<string, any>;
|
cover: Record<string, any>;
|
||||||
desc_data: Record<string, any>;
|
desc_data: Record<string, any>;
|
||||||
|
parent_card_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardWithDetails extends Card {
|
export interface CardWithDetails extends Card {
|
||||||
|
|
@ -193,6 +194,24 @@ export interface User {
|
||||||
last_name?: string;
|
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
|
// File Attachment types
|
||||||
export interface FileAttachment {
|
export interface FileAttachment {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue