Compare commits

..

5 commits

67 changed files with 3041 additions and 190 deletions

View file

@ -15,14 +15,16 @@ on:
jobs: jobs:
backend-test: backend-test:
runs-on: [docker] runs-on: [docker]
env:
UNIQUE_DB: test_db_${{ github.run_id }}
services: services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
env: env:
POSTGRES_USER: test POSTGRES_USER: test
POSTGRES_PASSWORD: test POSTGRES_PASSWORD: test
POSTGRES_DB: test_db POSTGRES_DB: test_db_${{ github.run_id }}_${{ github.run_attempt }}
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
--health-interval 10s --health-interval 10s
@ -39,13 +41,34 @@ jobs:
- name: Set up Python - name: Set up Python
run: | run: |
python --version python --version
- name: Install dependencies - name: Install dependencies
run: | run: |
cd backend cd backend
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install --cache-dir /tmp/pip-cache -r requirements/dev.txt pip install --cache-dir /tmp/pip-cache -r requirements/dev.txt
- name: Create Unique Test Database
env:
DATABASE_URL: postgresql://test:test@postgres:5432/postgres
run: |
# Install postgresql-client if not present in the alpine image to run psql
# Or use python to create the db
cd backend
python -c "
import os
from sqlalchemy import create_engine, text
db_url = os.environ['DATABASE_URL']
engine = create_engine(db_url)
new_db = os.environ['UNIQUE_DB']
# Connect to default 'postgres' db to create the new one
with engine.connect() as conn:
conn.execute(text('COMMIT')) # Close any open transactions
conn.execute(text(f'DROP DATABASE IF EXISTS {new_db}'))
conn.execute(text(f'CREATE DATABASE {new_db}'))
print(f'Created database: {new_db}')
"
- name: Debug cache - name: Debug cache
run: | run: |
echo "Listing PIP cache files:" echo "Listing PIP cache files:"
@ -59,8 +82,8 @@ jobs:
- name: Run tests - name: Run tests
env: env:
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db TEST_DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
DATABASE_URL: postgresql://test:test@postgres:5432/test_db DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
SECRET_KEY: test-secret-key SECRET_KEY: test-secret-key
JWT_SECRET_KEY: test-jwt-secret JWT_SECRET_KEY: test-jwt-secret
FLASK_ENV: test FLASK_ENV: test

View file

@ -88,8 +88,8 @@ class TestingConfig(Config):
# Conservative connection pool settings for testing # Conservative connection pool settings for testing
SQLALCHEMY_ENGINE_OPTIONS = { SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 1, # Only one connection in the pool "pool_size": 2, # Only one connection in the pool
"max_overflow": 0, # No overflow connections allowed "max_overflow": 2, # No overflow connections allowed
"pool_timeout": 30, "pool_timeout": 30,
"pool_recycle": 3600, # Recycle after 1 hour "pool_recycle": 3600, # Recycle after 1 hour
"pool_pre_ping": True, # Verify connections before using "pool_pre_ping": True, # Verify connections before using

View file

@ -29,16 +29,17 @@ def load_file_accessible(f):
user_id = get_current_user_id() user_id = get_current_user_id()
file_id = kwargs.get("file_id") file_id = kwargs.get("file_id")
# Try to find file uploaded by user # Try to find active file uploaded by user
attachment = FileAttachment.query.filter_by( attachment = (
id=file_id, uploaded_by=user_id FileAttachment.active().filter_by(id=file_id, uploaded_by=user_id).first()
).first() )
# If not found, check if attached to a Card that belongs to user's board # If not found, check if attached to a Card that belongs to user's board
if not attachment: if not attachment:
# For Card attachments # For Card attachments (only active files)
card_attachment = ( card_attachment = (
FileAttachment.query.join( FileAttachment.active()
.join(
Card, Card,
(FileAttachment.attachable_type == "Card") (FileAttachment.attachable_type == "Card")
& (FileAttachment.attachable_id == Card.id), & (FileAttachment.attachable_id == Card.id),
@ -56,9 +57,10 @@ def load_file_accessible(f):
# If still not found, check if attached # If still not found, check if attached
# to a Comment that belongs to user's board # to a Comment that belongs to user's board
if not attachment: if not attachment:
# For Comment attachments # For Comment attachments (only active files)
comment_attachment = ( comment_attachment = (
FileAttachment.query.join( FileAttachment.active()
.join(
Comment, Comment,
(FileAttachment.attachable_type == "Comment") (FileAttachment.attachable_type == "Comment")
& (FileAttachment.attachable_id == Comment.id), & (FileAttachment.attachable_id == Comment.id),
@ -98,16 +100,19 @@ def load_file_accessible_by_uuid(f):
user_id = get_current_user_id() user_id = get_current_user_id()
file_uuid = kwargs.get("file_uuid") file_uuid = kwargs.get("file_uuid")
# Try to find file uploaded by user # Try to find active file uploaded by user
attachment = FileAttachment.query.filter_by( attachment = (
uuid=file_uuid, uploaded_by=user_id FileAttachment.active()
).first() .filter_by(uuid=file_uuid, uploaded_by=user_id)
.first()
)
# If not found, check if attached to a Card that belongs to user's board # If not found, check if attached to a Card that belongs to user's board
if not attachment: if not attachment:
# For Card attachments # For Card attachments (only active files)
card_attachment = ( card_attachment = (
FileAttachment.query.join( FileAttachment.active()
.join(
Card, Card,
(FileAttachment.attachable_type == "Card") (FileAttachment.attachable_type == "Card")
& (FileAttachment.attachable_id == Card.id), & (FileAttachment.attachable_id == Card.id),
@ -125,9 +130,10 @@ def load_file_accessible_by_uuid(f):
# If still not found, check if attached to a # If still not found, check if attached to a
# Comment that belongs to user's board # Comment that belongs to user's board
if not attachment: if not attachment:
# For Comment attachments # For Comment attachments (only active files)
comment_attachment = ( comment_attachment = (
FileAttachment.query.join( FileAttachment.active()
.join(
Comment, Comment,
(FileAttachment.attachable_type == "Comment") (FileAttachment.attachable_type == "Comment")
& (FileAttachment.attachable_id == Comment.id), & (FileAttachment.attachable_id == Comment.id),

View file

@ -20,7 +20,7 @@ def load_board_owned(f):
board_id = kwargs.get("board_id") board_id = kwargs.get("board_id")
# SECURE QUERY: Filter by ID *and* User ID in the DB # SECURE QUERY: Filter by ID *and* User ID in the DB
board = Board.query.filter_by(id=board_id, user_id=user_id).first() board = Board.active().filter_by(id=board_id, user_id=user_id).first()
if not board: if not board:
abort(404) abort(404)
@ -35,6 +35,7 @@ def load_card_owned(f):
""" """
Loads a Card and ensures its Parent Board belongs to the current user. Loads a Card and ensures its Parent Board belongs to the current user.
Injects 'card' into the route kwargs. Injects 'card' into the route kwargs.
Aborts with 404 if not found, not owned, or soft-deleted.
""" """
@wraps(f) @wraps(f)
@ -42,9 +43,10 @@ def load_card_owned(f):
user_id = get_current_user_id() user_id = get_current_user_id()
card_id = kwargs.get("card_id") card_id = kwargs.get("card_id")
# Join Board to check ownership securely in one query # Join Board to check ownership and filter soft-deleted cards
card = ( card = (
Card.query.join(Board) Card.active()
.join(Board)
.filter(Card.id == card_id, Board.user_id == user_id) .filter(Card.id == card_id, Board.user_id == user_id)
.first() .first()
) )
@ -67,7 +69,8 @@ def load_list_owned(f):
list_id = kwargs.get("list_id") list_id = kwargs.get("list_id")
lst = ( lst = (
List.query.join(Board) List.active()
.join(Board)
.filter(List.id == list_id, Board.user_id == user_id) .filter(List.id == list_id, Board.user_id == user_id)
.first() .first()
) )
@ -90,7 +93,8 @@ def load_checklist_owned(f):
checklist_id = kwargs.get("checklist_id") checklist_id = kwargs.get("checklist_id")
checklist = ( checklist = (
Checklist.query.join(Card) Checklist.active()
.join(Card)
.join(Board) .join(Board)
.filter(Checklist.id == checklist_id, Board.user_id == user_id) .filter(Checklist.id == checklist_id, Board.user_id == user_id)
.first() .first()
@ -114,7 +118,8 @@ def load_check_item_owned(f):
item_id = kwargs.get("item_id") item_id = kwargs.get("item_id")
check_item = ( check_item = (
CheckItem.query.join(Checklist) CheckItem.active()
.join(Checklist)
.join(Card) .join(Card)
.join(Board) .join(Board)
.filter(CheckItem.id == item_id, Board.user_id == user_id) .filter(CheckItem.id == item_id, Board.user_id == user_id)
@ -141,7 +146,7 @@ def load_comment_owned(f):
user_id = get_current_user_id() user_id = get_current_user_id()
comment_id = kwargs.get("comment_id") comment_id = kwargs.get("comment_id")
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first() comment = Comment.active().filter_by(id=comment_id, user_id=user_id).first()
if not comment: if not comment:
abort(404) abort(404)
@ -164,9 +169,9 @@ def load_file_owned(f):
file_id = kwargs.get("file_id") file_id = kwargs.get("file_id")
# Filter by ID and user ID # Filter by ID and user ID
attachment = FileAttachment.query.filter_by( attachment = (
id=file_id, uploaded_by=user_id FileAttachment.active().filter_by(id=file_id, uploaded_by=user_id).first()
).first() )
if not attachment: if not attachment:
abort(404) abort(404)

View file

@ -1,6 +1,9 @@
# fmt: off
from app.models.base import SoftDeleteMixin
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
@ -12,12 +15,14 @@ from app.models.user import User
from app.models.wiki import Wiki, wiki_entity_links from app.models.wiki import Wiki, wiki_entity_links
__all__ = [ __all__ = [
"SoftDeleteMixin",
"User", "User",
"Board", "Board",
"List", "List",
"Card", "Card",
"Label", "Label",
"CardLabel", "CardLabel",
"CardLink",
"Checklist", "Checklist",
"CheckItem", "CheckItem",
"Comment", "Comment",
@ -26,3 +31,4 @@ __all__ = [
"Wiki", "Wiki",
"wiki_entity_links", "wiki_entity_links",
] ]
# fmt: on

137
backend/app/models/base.py Normal file
View file

@ -0,0 +1,137 @@
from datetime import UTC, datetime
from app import db
class SoftDeleteMixin:
"""Mixin that provides soft-delete functionality.
Instead of removing rows from the database, records are marked with
status='deleted' and a deleted_at timestamp. This preserves data
integrity and supports audit/recovery use-cases.
Usage:
class MyModel(db.Model, SoftDeleteMixin):
...
# Queries
MyModel.active().filter_by(name="foo").all()
# Soft-delete
instance = db.session.get(MyModel, 1)
instance.soft_delete()
db.session.commit()
# Restore
instance.restore()
db.session.commit()
For relationship filtering:
class Parent(db.Model, SoftDeleteMixin):
children = db.relationship(
"Child",
primaryjoin="and_(Parent.id == Child.parent_id, "
"Child.status == 'active')",
...
)
"""
STATUS_ACTIVE = "active"
STATUS_DELETED = "deleted"
status = db.Column(
db.String(20),
default=STATUS_ACTIVE,
nullable=False,
index=True,
)
deleted_at = db.Column(db.DateTime, nullable=True)
@classmethod
def active(cls):
"""Return a query scoped to non-deleted records only.
Usage:
Board.active().filter_by(user_id=1).all()
Card.active().order_by(Card.pos).all()
"""
return cls.query.filter(cls.status == cls.STATUS_ACTIVE)
@property
def is_active(self):
"""Check if this record is active (not soft-deleted)."""
return self.status == self.STATUS_ACTIVE
def soft_delete(self):
"""Mark this record as deleted and cascade to child relationships.
Every relationship defined with cascade="all, delete-orphan" or
cascade="all" is considered a *soft-deletable child* and will also
be soft-deleted recursively.
"""
self._do_soft_delete()
def _do_soft_delete(self):
"""Internal recursive soft-delete implementation."""
now = datetime.now(UTC)
# Soft-delete children from configured cascade relationships
for prop in self.__mapper__.relationships:
cascade = prop.cascade
if cascade is None:
continue
# Only cascade to relationships that had delete/delete-orphan
if not (cascade.delete or cascade.delete_orphan):
continue
# Get the related objects
children = getattr(self, prop.key, None)
if children is None:
continue
# Handle dynamic/lazy relationships (query-like)
if hasattr(children, "all"):
children = children.all()
# Handle single (many-to-one / scalar) relationships
if not isinstance(children, list):
children = [children] if children else []
for child in children:
if isinstance(child, SoftDeleteMixin):
child._do_soft_delete()
self.status = self.STATUS_DELETED
self.deleted_at = now
def restore(self):
"""Restore a soft-deleted record and cascade to children."""
self._do_restore()
def _do_restore(self):
"""Internal recursive restore implementation."""
for prop in self.__mapper__.relationships:
cascade = prop.cascade
if cascade is None:
continue
if not (cascade.delete or cascade.delete_orphan):
continue
children = getattr(self, prop.key, None)
if children is None:
continue
if hasattr(children, "all"):
children = children.all()
if not isinstance(children, list):
children = [children] if children else []
for child in children:
if isinstance(child, SoftDeleteMixin):
child._do_restore()
self.status = self.STATUS_ACTIVE
self.deleted_at = None

View file

@ -3,9 +3,10 @@ from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Board(db.Model): class Board(db.Model, SoftDeleteMixin):
"""Board model for Kanban boards""" """Board model for Kanban boards"""
__tablename__ = "boards" __tablename__ = "boards"
@ -41,15 +42,27 @@ class Board(db.Model):
label_names = db.Column(JSONB) # label color mappings label_names = db.Column(JSONB) # label color mappings
limits = db.Column(JSONB) # various limits limits = db.Column(JSONB) # various limits
# Relationships # Relationships - only active records
lists = db.relationship( lists = db.relationship(
"List", backref="board", cascade="all, delete-orphan", lazy="dynamic" "List",
backref="board",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Board.id == List.board_id, List.status == 'active')",
) )
cards = db.relationship( cards = db.relationship(
"Card", backref="board", cascade="all, delete-orphan", lazy="dynamic" "Card",
backref="board",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Board.id == Card.board_id, Card.status == 'active')",
) )
labels = db.relationship( labels = db.relationship(
"Label", backref="board", cascade="all, delete-orphan", lazy="dynamic" "Label",
backref="board",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Board.id == Label.board_id, Label.status == 'active')",
) )
def to_dict(self): def to_dict(self):
@ -74,6 +87,8 @@ class Board(db.Model):
"prefs": self.prefs, "prefs": self.prefs,
"label_names": self.label_names, "label_names": self.label_names,
"limits": self.limits, "limits": self.limits,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -3,9 +3,10 @@ from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Card(db.Model): class Card(db.Model, SoftDeleteMixin):
"""Card model for Kanban cards""" """Card model for Kanban cards"""
__tablename__ = "cards" __tablename__ = "cards"
@ -50,28 +51,61 @@ class Card(db.Model):
cover = db.Column(JSONB) # cover settings cover = db.Column(JSONB) # cover settings
desc_data = db.Column(JSONB) desc_data = db.Column(JSONB)
# Relationships # Relationships - only active records
checklists = db.relationship( checklists = db.relationship(
"Checklist", backref="card", cascade="all, delete-orphan", lazy="dynamic" "Checklist",
backref="card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Card.id == Checklist.card_id, Checklist.status == 'active')",
) )
labels = db.relationship( labels = db.relationship(
"CardLabel", backref="card", cascade="all, delete-orphan", lazy="dynamic" "CardLabel",
backref="card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Card.id == CardLabel.card_id, CardLabel.status == 'active')",
) )
comments = db.relationship( comments = db.relationship(
"Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic" "Comment",
backref="card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(Card.id == Comment.card_id, Comment.status == 'active')",
) )
attachments = db.relationship( attachments = db.relationship(
"FileAttachment", "FileAttachment",
foreign_keys="FileAttachment.attachable_id", foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Card.id, primaryjoin="""and_(FileAttachment.attachable_id == Card.id,
FileAttachment.attachable_type == 'Card')""", FileAttachment.attachable_type == 'Card',
FileAttachment.status == 'active')""",
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="dynamic", lazy="dynamic",
) )
def to_dict(self): # Card link relationships (self-referential many-to-many) - only active links
child_links = db.relationship(
"CardLink",
foreign_keys="CardLink.parent_card_id",
back_populates="parent_card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="""and_(Card.id == CardLink.parent_card_id,
CardLink.status == 'active')""",
)
parent_links = db.relationship(
"CardLink",
foreign_keys="CardLink.child_card_id",
back_populates="child_card",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="""and_(Card.id == CardLink.child_card_id,
CardLink.status == 'active')""",
)
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,6 +116,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 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
@ -91,7 +126,26 @@ 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,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
"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}>"
@ -105,19 +159,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(
@ -125,7 +176,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)
@ -145,19 +195,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(
@ -165,7 +212,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)
@ -185,19 +231,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(
@ -205,7 +248,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)

View file

@ -1,9 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class CardLabel(db.Model): class CardLabel(db.Model, SoftDeleteMixin):
"""Many-to-many relationship between cards and labels""" """Many-to-many relationship between cards and labels"""
__tablename__ = "card_labels" __tablename__ = "card_labels"
@ -37,6 +38,8 @@ class CardLabel(db.Model):
"card_id": self.card_id, "card_id": self.card_id,
"label_id": self.label_id, "label_id": self.label_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -0,0 +1,69 @@
from datetime import UTC, datetime
from app import db
from app.models.base import SoftDeleteMixin
class CardLink(db.Model, SoftDeleteMixin):
"""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,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_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,9 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class CheckItem(db.Model): class CheckItem(db.Model, SoftDeleteMixin):
"""CheckItem model for checklist items""" """CheckItem model for checklist items"""
__tablename__ = "check_items" __tablename__ = "check_items"
@ -45,6 +46,8 @@ class CheckItem(db.Model):
"user_id": self.user_id, "user_id": self.user_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -1,10 +1,11 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Checklist(db.Model): class Checklist(db.Model, SoftDeleteMixin):
"""Checklist model for Kanban checklists""" """Checklist model for card checklists"""
__tablename__ = "checklists" __tablename__ = "checklists"
@ -34,9 +35,14 @@ class Checklist(db.Model):
onupdate=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC),
) )
# Relationships # Relationships - only active check items
check_items = db.relationship( check_items = db.relationship(
"CheckItem", backref="checklist", cascade="all, delete-orphan", lazy="dynamic" "CheckItem",
backref="checklist",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="""and_(Checklist.id == CheckItem.checklist_id,
CheckItem.status == 'active')""",
) )
def to_dict(self): def to_dict(self):
@ -45,10 +51,12 @@ class Checklist(db.Model):
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"pos": self.pos, "pos": self.pos,
"board_id": self.board_id,
"card_id": self.card_id, "card_id": self.card_id,
"board_id": self.card.board_id if self.card else None,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -1,9 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Comment(db.Model): class Comment(db.Model, SoftDeleteMixin):
"""Comment model for card comments""" """Comment model for card comments"""
__tablename__ = "comments" __tablename__ = "comments"
@ -50,6 +51,8 @@ class Comment(db.Model):
"user_id": self.user_id, "user_id": self.user_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -3,9 +3,10 @@ from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Epic(db.Model): class Epic(db.Model, SoftDeleteMixin):
"""Epic model for tracking large features across multiple cards""" """Epic model for tracking large features across multiple cards"""
__tablename__ = "epics" __tablename__ = "epics"
@ -81,6 +82,8 @@ class Epic(db.Model):
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
"metrics": self.metrics or {"card_count": 0, "completed_cards_count": 0}, "metrics": self.metrics or {"card_count": 0, "completed_cards_count": 0},
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -4,9 +4,10 @@ from datetime import UTC, datetime
from sqlalchemy import Index from sqlalchemy import Index
from app import db from app import db
from app.models.base import SoftDeleteMixin
class FileAttachment(db.Model): class FileAttachment(db.Model, SoftDeleteMixin):
"""Polymorphic file attachment model for Cards, Comments, and other entities""" """Polymorphic file attachment model for Cards, Comments, and other entities"""
__tablename__ = "file_attachments" __tablename__ = "file_attachments"
@ -69,6 +70,8 @@ class FileAttachment(db.Model):
"attachable_id": self.attachable_id, "attachable_id": self.attachable_id,
"uploaded_by": self.uploaded_by, "uploaded_by": self.uploaded_by,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -1,10 +1,11 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Label(db.Model): class Label(db.Model, SoftDeleteMixin):
"""Label model for Kanban labels""" """Label model for card labels"""
__tablename__ = "labels" __tablename__ = "labels"
@ -40,11 +41,12 @@ class Label(db.Model):
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"color": self.color, "color": self.color,
"uses": self.uses,
"board_id": self.board_id, "board_id": self.board_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):
return f"<Label {self.name} ({self.color})>" return f"<Label {self.name}>"

View file

@ -1,9 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class List(db.Model): class List(db.Model, SoftDeleteMixin):
"""List model for Kanban lists (columns)""" """List model for Kanban lists (columns)"""
__tablename__ = "lists" __tablename__ = "lists"
@ -29,9 +30,13 @@ class List(db.Model):
onupdate=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC),
) )
# Relationships # Relationships - only active cards
cards = db.relationship( cards = db.relationship(
"Card", backref="list", cascade="all, delete-orphan", lazy="dynamic" "Card",
backref="list",
cascade="all, delete-orphan",
lazy="dynamic",
primaryjoin="and_(List.id == Card.list_id, Card.status == 'active')",
) )
def to_dict(self): def to_dict(self):
@ -44,6 +49,8 @@ class List(db.Model):
"board_id": self.board_id, "board_id": self.board_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -4,6 +4,7 @@ from sqlalchemy import and_
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from app import db from app import db
from app.models.base import SoftDeleteMixin
from app.models.card import Card from app.models.card import Card
from app.models.epic import Epic from app.models.epic import Epic
@ -22,7 +23,7 @@ wiki_entity_links = db.Table(
) )
class Wiki(db.Model): class Wiki(db.Model, SoftDeleteMixin):
"""Wiki model for reusable rich text content within a board""" """Wiki model for reusable rich text content within a board"""
__tablename__ = "wikis" __tablename__ = "wikis"
@ -105,6 +106,8 @@ class Wiki(db.Model):
"updated_by": self.updated_by, "updated_by": self.updated_by,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -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

View file

@ -7,7 +7,7 @@ from flask_pydantic import validate
from app import db from app import db
from app.decorators import load_board_owned from app.decorators import load_board_owned
from app.decorators.decorators import get_current_user_id from app.decorators.decorators import get_current_user_id
from app.models import Board, Card, CardLabel, Label, List from app.models import Board, Card, List
from app.schemas import (BoardCreateRequest, BoardResponse, from app.schemas import (BoardCreateRequest, BoardResponse,
BoardWithDetailsResponse) BoardWithDetailsResponse)
@ -19,7 +19,7 @@ from . import kanban_bp
def get_boards(): def get_boards():
"""Get all boards for current user""" """Get all boards for current user"""
user_id = get_current_user_id() user_id = get_current_user_id()
boards = Board.query.filter_by(user_id=user_id).all() boards = Board.active().filter_by(user_id=user_id).all()
return [BoardResponse.model_validate(board).model_dump() for board in boards], 200 return [BoardResponse.model_validate(board).model_dump() for board in boards], 200
@ -39,13 +39,9 @@ def get_board(board_id, board):
# Add labels for this card # Add labels for this card
card_dict["labels"] = [ card_dict["labels"] = [
label.to_dict() card_label.label.to_dict()
for label in ( for card_label in card.labels
db.session.query(Label) if card_label.label and card_label.status == "active"
.join(CardLabel)
.filter(CardLabel.card_id == card.id)
.all()
)
] ]
# Add comments for this card # Add comments for this card
@ -56,20 +52,24 @@ def get_board(board_id, board):
comment_dict["user"] = user.to_dict() if user else None comment_dict["user"] = user.to_dict() if user else None
card_dict["comments"].append(comment_dict) card_dict["comments"].append(comment_dict)
# Add checklists with items for this card # Add checklists with items
from app.models import CheckItem, Checklist
card_dict["checklists"] = [ card_dict["checklists"] = [
{ {
**checklist.to_dict(), **checklist.to_dict(),
"items": [item.to_dict() for item in checklist.check_items.all()], "items": [
item.to_dict()
for item in CheckItem.active()
.filter_by(checklist_id=checklist.id)
.all()
],
} }
for checklist in card.checklists.all() for checklist in Checklist.active().filter_by(card_id=card.id).all()
] ]
# Add epic for this card # Add epic for this card
if card.epic: card_dict["epic"] = card.epic.to_dict() if card.epic else None
card_dict["epic"] = card.epic.to_dict()
else:
card_dict["epic"] = None
cards_data.append(card_dict) cards_data.append(card_dict)
@ -142,7 +142,7 @@ def update_board(board_id, board, body: BoardCreateRequest):
@load_board_owned @load_board_owned
def delete_board(board_id, board): def delete_board(board_id, board):
"""Delete a board""" """Delete a board"""
db.session.delete(board) board.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Board deleted"}, 200 return {"message": "Board deleted"}, 200

View file

@ -0,0 +1,278 @@
"""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
link.soft_delete()
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
link.soft_delete()
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
check_item.soft_delete()
db.session.commit()
return (
jsonify(
{
"card": new_card.to_dict(),
"link": link.to_dict(),
}
),
201,
)

View file

@ -42,7 +42,7 @@ def create_card(list_id, lst, body: CardCreateRequest):
@kanban_bp.route("/cards/<int:card_id>", methods=["GET"]) @kanban_bp.route("/cards/<int:card_id>", methods=["GET"])
@jwt_required() @jwt_required()
@load_card_owned @load_card_owned
def get_card(card_id, card): def get_card(card_id, card: Card):
"""Get a single card with full details""" """Get a single card with full details"""
from app.models import User from app.models import User
@ -146,9 +146,9 @@ def update_card(card_id, card, body: CardCreateRequest):
@kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"]) @kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"])
@jwt_required() @jwt_required()
@load_card_owned @load_card_owned
def delete_card(card_id, card): def delete_card(card_id, card: Card):
"""Delete a card""" """Delete a card"""
db.session.delete(card) card.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Card deleted"}, 200 return {"message": "Card deleted"}, 200

View file

@ -70,7 +70,7 @@ def update_check_item(item_id, check_item, body: CheckItemCreateRequest):
@load_checklist_owned @load_checklist_owned
def delete_checklist(checklist_id, checklist): def delete_checklist(checklist_id, checklist):
"""Delete a checklist""" """Delete a checklist"""
db.session.delete(checklist) checklist.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Checklist deleted"}, 200 return {"message": "Checklist deleted"}, 200
@ -81,7 +81,7 @@ def delete_checklist(checklist_id, checklist):
@load_check_item_owned @load_check_item_owned
def delete_check_item(item_id, check_item): def delete_check_item(item_id, check_item):
"""Delete a check item""" """Delete a check item"""
db.session.delete(check_item) check_item.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Check item deleted"}, 200 return {"message": "Check item deleted"}, 200

View file

@ -66,7 +66,7 @@ def update_comment(comment_id, comment, body: CommentCreateRequest):
@load_comment_owned @load_comment_owned
def delete_comment(comment_id, comment): def delete_comment(comment_id, comment):
"""Delete a comment""" """Delete a comment"""
db.session.delete(comment) comment.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Comment deleted"}, 200 return {"message": "Comment deleted"}, 200

View file

@ -22,7 +22,7 @@ def get_board_epics(board_id):
if not board: if not board:
return {"error": "Board not found"}, 404 return {"error": "Board not found"}, 404
epics = Epic.query.filter_by(board_id=board_id).all() epics = Epic.active().filter_by(board_id=board_id).all()
return epics, 200 return epics, 200
@ -65,7 +65,7 @@ def get_epic(epic_id):
return {"error": "Epic not found"}, 404 return {"error": "Epic not found"}, 404
# Get cards for this epic # Get cards for this epic
cards = Card.query.filter_by(epic_id=epic_id).all() cards = Card.active().filter_by(epic_id=epic_id).all()
epic_dict = EpicResponse.model_validate(epic).model_dump() epic_dict = EpicResponse.model_validate(epic).model_dump()
epic_dict["cards"] = [card.to_dict() for card in cards] epic_dict["cards"] = [card.to_dict() for card in cards]
@ -119,10 +119,10 @@ def delete_epic(epic_id):
return {"error": "Epic not found"}, 404 return {"error": "Epic not found"}, 404
# Unlink all cards from this epic # Unlink all cards from this epic
Card.query.filter_by(epic_id=epic_id).update({"epic_id": None}) Card.active().filter_by(epic_id=epic_id).update({"epic_id": None})
# Delete epic # Delete epic
db.session.delete(epic) epic.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Epic deleted successfully"}, 200 return {"message": "Epic deleted successfully"}, 200

View file

@ -73,7 +73,7 @@ def remove_label_from_card(card_id, card, label_id):
if not card_label: if not card_label:
return {"error": "Label not found on card"}, 404 return {"error": "Label not found on card"}, 404
db.session.delete(card_label) card_label.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Label removed from card"}, 200 return {"message": "Label removed from card"}, 200

View file

@ -64,7 +64,7 @@ def update_list(list_id, lst, body: ListCreateRequest):
@load_list_owned @load_list_owned
def delete_list(list_id, lst): def delete_list(list_id, lst):
"""Delete a list""" """Delete a list"""
db.session.delete(lst) lst.soft_delete()
db.session.commit() db.session.commit()
return {"message": "List deleted"}, 200 return {"message": "List deleted"}, 200

View file

@ -37,7 +37,7 @@ def get_board_wikis(board_id):
if not board: if not board:
return {"error": "Board not found"}, 404 return {"error": "Board not found"}, 404
wikis = Wiki.query.filter_by(board_id=board_id).all() wikis = Wiki.active().filter_by(board_id=board_id).all()
return wikis, 200 return wikis, 200
@ -147,7 +147,7 @@ def delete_wiki(wiki_id):
return {"error": "Wiki not found"}, 404 return {"error": "Wiki not found"}, 404
# Delete wiki (cascades to wiki_entity_links) # Delete wiki (cascades to wiki_entity_links)
db.session.delete(wiki) wiki.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Wiki deleted successfully"}, 200 return {"message": "Wiki deleted successfully"}, 200

View file

@ -87,8 +87,11 @@ class CardWithDetailsResponse(BaseModel):
"closed": False, "closed": False,
"pos": 65535.0, "pos": 65535.0,
"list_id": 1, "list_id": 1,
"list_name": "list 1",
"board_id": 1, "board_id": 1,
"due": "2024-12-31T23:59:59", "due": "2024-12-31T23:59:59",
"created_at": "2024-12-31T23:59:59",
"updated_at": "2024-12-31T23:59:59",
"due_complete": False, "due_complete": False,
"badges": {"votes": 0}, "badges": {"votes": 0},
"cover": "https://example.com/cover.jpg", "cover": "https://example.com/cover.jpg",
@ -118,3 +121,6 @@ class CardWithDetailsResponse(BaseModel):
checklists: List[Dict[str, Any]] = Field(default_factory=list) checklists: List[Dict[str, Any]] = Field(default_factory=list)
comments: List[Dict[str, Any]] = Field(default_factory=list) comments: List[Dict[str, Any]] = Field(default_factory=list)
epic: Optional[Dict[str, Any]] = None epic: Optional[Dict[str, Any]] = None
created_at: datetime
updated_at: datetime
list_name: str

View file

@ -171,8 +171,7 @@ class FileService:
current_app.logger.error(f"Error deleting file from MinIO: {e}") current_app.logger.error(f"Error deleting file from MinIO: {e}")
# Delete from database # Delete from database
db.session.delete(attachment) attachment.soft_delete()
db.session.commit()
current_app.logger.info(f"File deleted: {attachment.original_name}") current_app.logger.info(f"File deleted: {attachment.original_name}")
return True return True

View file

@ -0,0 +1,146 @@
"""add soft delete columns
Revision ID: 7a0cfda486e1
Revises: bf430156bcf2
Create Date: 2026-05-01 13:40:24.316892
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7a0cfda486e1'
down_revision = 'bf430156bcf2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('boards', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_boards_status'), ['status'], unique=False)
with op.batch_alter_table('card_labels', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_card_labels_status'), ['status'], unique=False)
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_card_links_status'), ['status'], unique=False)
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_cards_status'), ['status'], unique=False)
with op.batch_alter_table('check_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_check_items_status'), ['status'], unique=False)
with op.batch_alter_table('checklists', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_checklists_status'), ['status'], unique=False)
with op.batch_alter_table('comments', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_comments_status'), ['status'], unique=False)
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_epics_status'), ['status'], unique=False)
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_file_attachments_status'), ['status'], unique=False)
with op.batch_alter_table('labels', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_labels_status'), ['status'], unique=False)
with op.batch_alter_table('lists', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_lists_status'), ['status'], unique=False)
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_wikis_status'), ['status'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_wikis_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('lists', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_lists_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('labels', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_labels_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_file_attachments_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_epics_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('comments', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comments_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('checklists', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_checklists_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('check_items', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_check_items_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_cards_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_card_links_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('card_labels', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_card_labels_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('boards', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_boards_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
# ### end Alembic commands ###

View file

@ -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'))

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

@ -14,7 +14,7 @@ log = logging.getLogger(__name__)
fake = Faker() fake = Faker()
@pytest.fixture(scope="function") @pytest.fixture(scope="session")
def app(): def app():
"""Create application for testing with PostgreSQL database (session scope)""" """Create application for testing with PostgreSQL database (session scope)"""
app = create_app(config_name="test") app = create_app(config_name="test")

View file

@ -1,7 +1,8 @@
import pytest import pytest
from app import db from app import db
from app.models import Board, Card, List from app.models import (Board, Card, CardLabel, CheckItem, Checklist, Comment,
Label, List)
@pytest.mark.integration @pytest.mark.integration
@ -147,7 +148,8 @@ class TestBoardRoutes:
# Verify board is deleted # Verify board is deleted
deleted_board = db.session.get(Board, board.id) deleted_board = db.session.get(Board, board.id)
assert deleted_board is None assert deleted_board is not None
assert deleted_board.status == "deleted"
def test_delete_board_not_found(self, client, db_session, auth_headers): def test_delete_board_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent board""" """Test deleting a non-existent board"""
@ -164,3 +166,328 @@ class TestBoardRoutes:
response = client.delete(f"/api/boards/{board.id}") response = client.delete(f"/api/boards/{board.id}")
assert response.status_code == 401 assert response.status_code == 401
def test_get_board_filters_deleted_cards(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted cards are filtered out from board response"""
# Create board with list and 2 cards
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card1 = Card(name="Active Card", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Deleted Card", board_id=board.id, list_id=lst.id, pos=1)
db_session.add_all([card1, card2])
db_session.commit()
# Soft delete card2
card2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 card (the active one)
assert len(data["lists"][0]["cards"]) == 1
assert data["lists"][0]["cards"][0]["name"] == "Active Card"
def test_get_board_filters_deleted_lists(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted lists are filtered out from board response"""
# Create board with 2 lists
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst1 = List(name="To Do", board_id=board.id, pos=0)
lst2 = List(name="Deleted List", board_id=board.id, pos=1)
db_session.add_all([lst1, lst2])
db_session.commit()
# Soft delete lst2
lst2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 list (the active one)
assert len(data["lists"]) == 1
assert data["lists"][0]["name"] == "To Do"
def test_get_board_filters_deleted_comments(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted comments are filtered out from card response"""
# Create board, list, card and 2 comments
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
comment1 = Comment(
text="Active comment", card_id=card.id, user_id=regular_user.id
)
comment2 = Comment(
text="Deleted comment", card_id=card.id, user_id=regular_user.id
)
db_session.add_all([comment1, comment2])
db_session.commit()
# Soft delete comment2
comment2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 comment (the active one)
assert len(data["lists"][0]["cards"][0]["comments"]) == 1
assert data["lists"][0]["cards"][0]["comments"][0]["text"] == "Active comment"
def test_get_board_filters_deleted_checklists(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted checklists are filtered out from card response"""
# Create board, list, card and 2 checklists
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist1 = Checklist(
name="Active Checklist", board_id=board.id, card_id=card.id, pos=0
)
checklist2 = Checklist(
name="Deleted Checklist", board_id=board.id, card_id=card.id, pos=1
)
db_session.add_all([checklist1, checklist2])
db_session.commit()
# Soft delete checklist2
checklist2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 checklist (the active one)
assert len(data["lists"][0]["cards"][0]["checklists"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["name"] == "Active Checklist"
)
def test_get_board_filters_deleted_check_items(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted check items are
filtered out from checklist response"""
# Create board, list, card, checklist and 2 check items
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.flush()
item1 = CheckItem(name="Active Task", checklist_id=checklist.id, pos=0)
item2 = CheckItem(name="Deleted Task", checklist_id=checklist.id, pos=1)
db_session.add_all([item1, item2])
db_session.commit()
# Soft delete item2
item2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 check item (the active one)
assert len(data["lists"][0]["cards"][0]["checklists"][0]["items"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["items"][0]["name"]
== "Active Task"
)
def test_get_board_filters_deleted_card_labels(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted card-label associations are filtered out"""
# Create board, list, card, label and 2 card-label associations
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
label1 = Label(name="Urgent", color="red", board_id=board.id)
label2 = Label(name="Important", color="yellow", board_id=board.id)
db_session.add_all([label1, label2])
db_session.flush()
card_label1 = CardLabel(card_id=card.id, label_id=label1.id)
card_label2 = CardLabel(card_id=card.id, label_id=label2.id)
db_session.add_all([card_label1, card_label2])
db_session.commit()
# Soft delete card_label2
card_label2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 label (the active one)
assert len(data["lists"][0]["cards"][0]["labels"]) == 1
assert data["lists"][0]["cards"][0]["labels"][0]["name"] == "Urgent"
def test_get_board_comprehensive_soft_delete_filtering(
self, client, db_session, regular_user, auth_headers
):
"""Test comprehensive soft delete filtering across all nested resources"""
# Create board with all types of nested resources, some deleted
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
# Active list
lst1 = List(name="Active List", board_id=board.id, pos=0)
db_session.add(lst1)
db_session.flush()
card1 = Card(name="Active Card 1", board_id=board.id, list_id=lst1.id, pos=0)
card2 = Card(name="Deleted Card", board_id=board.id, list_id=lst1.id, pos=1)
db_session.add_all([card1, card2])
db_session.flush()
# Active comment
comment1 = Comment(
text="Active comment", card_id=card1.id, user_id=regular_user.id
)
# Deleted comment
comment2 = Comment(
text="Deleted comment", card_id=card1.id, user_id=regular_user.id
)
db_session.add_all([comment1, comment2])
db_session.flush()
# Active checklist
checklist1 = Checklist(
name="Active Checklist", board_id=board.id, card_id=card1.id, pos=0
)
# Deleted checklist
checklist2 = Checklist(
name="Deleted Checklist", board_id=board.id, card_id=card1.id, pos=1
)
db_session.add_all([checklist1, checklist2])
db_session.flush()
# Active check item
item1 = CheckItem(name="Active Task", checklist_id=checklist1.id, pos=0)
# Deleted check item
item2 = CheckItem(name="Deleted Task", checklist_id=checklist1.id, pos=1)
db_session.add_all([item1, item2])
db_session.flush()
# Labels
label1 = Label(name="Active Label", color="red", board_id=board.id)
label2 = Label(name="Deleted Label", color="yellow", board_id=board.id)
db_session.add_all([label1, label2])
db_session.flush()
# Active card-label
card_label1 = CardLabel(card_id=card1.id, label_id=label1.id)
# Deleted card-label
card_label2 = CardLabel(card_id=card1.id, label_id=label2.id)
db_session.add_all([card_label1, card_label2])
db_session.commit()
# Soft delete some resources
card2.soft_delete()
comment2.soft_delete()
checklist2.soft_delete()
item2.soft_delete()
card_label2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Verify lists: should have 1 list (we didn't delete the list)
assert len(data["lists"]) == 1
assert data["lists"][0]["name"] == "Active List"
# Verify cards: should have 1 card (card2 is deleted)
assert len(data["lists"][0]["cards"]) == 1
assert data["lists"][0]["cards"][0]["name"] == "Active Card 1"
# Verify comments: should have 1 comment (comment2 is deleted)
assert len(data["lists"][0]["cards"][0]["comments"]) == 1
assert data["lists"][0]["cards"][0]["comments"][0]["text"] == "Active comment"
# Verify checklists: should have 1 checklist (checklist2 is deleted)
assert len(data["lists"][0]["cards"][0]["checklists"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["name"] == "Active Checklist"
)
# Verify check items: should have 1 item (item2 is deleted)
assert len(data["lists"][0]["cards"][0]["checklists"][0]["items"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["items"][0]["name"]
== "Active Task"
)
# Verify labels: should have 1 label (card_label2 is deleted)
assert len(data["lists"][0]["cards"][0]["labels"]) == 1
assert data["lists"][0]["cards"][0]["labels"][0]["name"] == "Active Label"

View file

@ -160,7 +160,8 @@ class TestCardRoutes:
# Verify card is deleted # Verify card is deleted
deleted_card = db.session.get(Card, card.id) deleted_card = db.session.get(Card, card.id)
assert deleted_card is None assert deleted_card is not None
assert deleted_card.status == "deleted"
def test_delete_card_not_found(self, client, db_session, auth_headers): def test_delete_card_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent card""" """Test deleting a non-existent card"""
@ -168,6 +169,38 @@ class TestCardRoutes:
assert response.status_code == 404 assert response.status_code == 404
def test_get_soft_deleted_card_returns_404(
self, client, db_session, regular_user, auth_headers
):
"""Test that getting a soft-deleted card returns 404"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="To Delete", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.commit()
# Soft delete the card
card.soft_delete()
db_session.commit()
# Verify card is deleted in database
deleted_card = db.session.get(Card, card.id)
assert deleted_card is not None
assert deleted_card.status == "deleted"
assert deleted_card.deleted_at is not None
# Try to get the deleted card via API
response = client.get(f"/api/cards/{card.id}", headers=auth_headers)
# Should return 404
assert response.status_code == 404
def test_update_card_position_within_same_list( def test_update_card_position_within_same_list(
self, client, db_session, regular_user, auth_headers self, client, db_session, regular_user, auth_headers
): ):

View file

@ -245,7 +245,8 @@ class TestChecklistRoutes:
# Verify checklist is deleted # Verify checklist is deleted
deleted_checklist = db.session.get(Checklist, checklist.id) deleted_checklist = db.session.get(Checklist, checklist.id)
assert deleted_checklist is None assert deleted_checklist is not None
assert deleted_checklist.status == "deleted"
def test_delete_checklist_not_found(self, client, db_session, auth_headers): def test_delete_checklist_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent checklist""" """Test deleting a non-existent checklist"""
@ -287,7 +288,8 @@ class TestChecklistRoutes:
# Verify check item is deleted # Verify check item is deleted
deleted_item = db.session.get(CheckItem, item.id) deleted_item = db.session.get(CheckItem, item.id)
assert deleted_item is None assert deleted_item is not None
assert deleted_item.status == "deleted"
def test_delete_check_item_not_found(self, client, db_session, auth_headers): def test_delete_check_item_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent check item""" """Test deleting a non-existent check item"""

View file

@ -196,7 +196,8 @@ class TestCommentRoutes:
# Verify comment is deleted # Verify comment is deleted
deleted_comment = db.session.get(Comment, comment.id) deleted_comment = db.session.get(Comment, comment.id)
assert deleted_comment is None assert deleted_comment is not None
assert deleted_comment.status == "deleted"
def test_delete_comment_not_found(self, client, db_session, auth_headers): def test_delete_comment_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent comment""" """Test deleting a non-existent comment"""

View file

@ -416,7 +416,8 @@ class TestEpicRoutes:
# Verify epic is deleted # Verify epic is deleted
deleted_epic = db.session.get(Epic, epic_id) deleted_epic = db.session.get(Epic, epic_id)
assert deleted_epic is None assert deleted_epic is not None
assert deleted_epic.status == "deleted"
def test_delete_epic_with_cards( def test_delete_epic_with_cards(
self, client, db_session, auth_headers, test_board, test_card self, client, db_session, auth_headers, test_board, test_card

View file

@ -181,4 +181,5 @@ class TestLabelRoutes:
.filter_by(card_id=card.id, label_id=label.id) .filter_by(card_id=card.id, label_id=label.id)
.first() .first()
) )
assert deleted_card_label is None assert deleted_card_label is not None
assert deleted_card_label.status == "deleted"

View file

@ -116,7 +116,8 @@ class TestListRoutes:
# Verify list is deleted # Verify list is deleted
deleted_list = db.session.get(List, lst.id) deleted_list = db.session.get(List, lst.id)
assert deleted_list is None assert deleted_list is not None
assert deleted_list.status == "deleted"
def test_delete_list_not_found(self, client, db_session, auth_headers): def test_delete_list_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent list""" """Test deleting a non-existent list"""
@ -144,11 +145,13 @@ class TestListRoutes:
assert response.status_code == 200 assert response.status_code == 200
# Verify both list and card are deleted # Verify both list and card are soft-deleted
deleted_list = db.session.get(List, lst.id) deleted_list = db.session.get(List, lst.id)
deleted_card = db.session.get(Card, card.id) deleted_card = db.session.get(Card, card.id)
assert deleted_list is None assert deleted_list is not None
assert deleted_card is None assert deleted_list.status == "deleted"
assert deleted_card is not None
assert deleted_card.status == "deleted"
def test_update_list_position_reorders_others( def test_update_list_position_reorders_others(
self, client, db_session, regular_user, auth_headers self, client, db_session, regular_user, auth_headers

View file

@ -0,0 +1,496 @@
"""High-level integration tests for soft delete functionality
These tests verify that soft delete works correctly by checking:
1. The delete endpoint returns success
2. The record is marked as deleted in the database (deleted_at is set)
3. The record still exists (soft delete, not hard delete)
"""
import pytest
from app import db
from app.models import (Board, Card, CardLabel, CardLink, CheckItem, Checklist,
Comment, Epic, Label, List, Wiki)
@pytest.mark.integration
class TestSoftDeleteIntegration:
"""High-level integration tests for soft delete across all resources"""
def test_soft_delete_card_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a card marks it as deleted in the database"""
# Create board, list and card
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.commit()
# Delete the card
response = client.delete(f"/api/cards/{card.id}", headers=auth_headers)
assert response.status_code == 200
# Verify card is marked as deleted in database
deleted_card = db.session.get(Card, card.id)
assert deleted_card is not None
assert deleted_card.deleted_at is not None
def test_soft_delete_list_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a list marks it as deleted in the database"""
# Create board and list
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.commit()
# Delete the list
response = client.delete(f"/api/lists/{lst.id}", headers=auth_headers)
assert response.status_code == 200
# Verify list is marked as deleted in database
deleted_list = db.session.get(List, lst.id)
assert deleted_list is not None
assert deleted_list.deleted_at is not None
def test_soft_delete_comment_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a comment marks it as deleted in the database"""
# Create board, list, card and comment
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
comment = Comment(text="Test comment", card_id=card.id, user_id=regular_user.id)
db_session.add(comment)
db_session.commit()
# Delete the comment
response = client.delete(f"/api/comments/{comment.id}", headers=auth_headers)
assert response.status_code == 200
# Verify comment is marked as deleted in database
deleted_comment = db.session.get(Comment, comment.id)
assert deleted_comment is not None
assert deleted_comment.deleted_at is not None
def test_soft_delete_checklist_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a checklist marks it as deleted in the database"""
# Create board, list, card and checklist
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.commit()
# Delete the checklist
response = client.delete(
f"/api/checklists/{checklist.id}", headers=auth_headers
)
assert response.status_code == 200
# Verify checklist is marked as deleted in database
deleted_checklist = db.session.get(Checklist, checklist.id)
assert deleted_checklist is not None
assert deleted_checklist.deleted_at is not None
def test_soft_delete_check_item_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a check item marks it as deleted in the database"""
# Create board, list, card, checklist and check item
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.flush()
item = CheckItem(
name="Task", checklist_id=checklist.id, pos=0, state="incomplete"
)
db_session.add(item)
db_session.commit()
# Delete the check item
response = client.delete(f"/api/check-items/{item.id}", headers=auth_headers)
assert response.status_code == 200
# Verify check item is marked as deleted in database
deleted_item = db.session.get(CheckItem, item.id)
assert deleted_item is not None
assert deleted_item.deleted_at is not None
def test_soft_delete_card_label_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that removing a label from a card marks the association as deleted"""
# Create board, list, card and labels
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
label = Label(name="Urgent", color="red", board_id=board.id)
db_session.add(label)
db_session.flush()
card_label = CardLabel(card_id=card.id, label_id=label.id)
db_session.add(card_label)
db_session.commit()
# Remove label from card
response = client.delete(
f"/api/cards/{card.id}/labels/{label.id}", headers=auth_headers
)
assert response.status_code == 200
# Verify card-label association is marked as deleted in database
deleted_card_label = (
db_session.query(CardLabel)
.filter_by(card_id=card.id, label_id=label.id)
.first()
)
assert deleted_card_label is not None
assert deleted_card_label.deleted_at is not None
def test_soft_delete_epic_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting an epic marks it as deleted in the database"""
# Create board and epic
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
epic = Epic(name="Test Epic", board_id=board.id, color="#3b82f6")
db_session.add(epic)
db_session.commit()
# Delete the epic
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
# Verify epic is marked as deleted in database
deleted_epic = db.session.get(Epic, epic.id)
assert deleted_epic is not None
assert deleted_epic.deleted_at is not None
def test_soft_delete_epic_unlinks_card(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleting an epic unlinks associated cards"""
# Create board, list, card and epic
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
epic = Epic(name="Test Epic", board_id=board.id, color="#3b82f6")
db_session.add(epic)
db_session.flush()
card.epic_id = epic.id
db_session.commit()
# Delete epic
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
# Verify card no longer has epic
db.session.refresh(card)
assert card.epic_id is None
def test_soft_delete_wiki_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a wiki marks it as deleted in the database"""
# Create board and wiki
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
wiki = Wiki(
name="Test Wiki",
board_id=board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Delete the wiki
response = client.delete(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
# Verify wiki is marked as deleted in database
deleted_wiki = db.session.get(Wiki, wiki.id)
assert deleted_wiki is not None
assert deleted_wiki.deleted_at is not None
def test_soft_delete_board_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a board marks it as deleted in the database"""
# Create board
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.commit()
# Delete the board
response = client.delete(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
# Verify board is marked as deleted in database
deleted_board = db.session.get(Board, board.id)
assert deleted_board is not None
assert deleted_board.status == "deleted"
def test_soft_delete_card_link_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a card link marks it as deleted in the database"""
# Create board, list and cards
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
parent_card = Card(name="Parent Card", board_id=board.id, list_id=lst.id, pos=0)
child_card = Card(name="Child Card", board_id=board.id, list_id=lst.id, pos=1)
db_session.add_all([parent_card, child_card])
db_session.commit()
# Create card link
link = CardLink(
parent_card_id=parent_card.id,
child_card_id=child_card.id,
created_by=regular_user.id,
)
db_session.add(link)
db_session.commit()
# Delete the link
response = client.delete(f"/api/card-links/{link.id}", headers=auth_headers)
assert response.status_code == 200
# Verify link is marked as deleted in database
deleted_link = db.session.get(CardLink, link.id)
assert deleted_link is not None
assert deleted_link.deleted_at is not None
def test_soft_delete_cascade_from_list_to_cards(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a list soft-deletes its cards"""
# Create board, list and cards
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card1 = Card(name="Card 1", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=lst.id, pos=1)
db_session.add_all([card1, card2])
db_session.commit()
# Delete list
response = client.delete(f"/api/lists/{lst.id}", headers=auth_headers)
assert response.status_code == 200
# Verify list is soft-deleted
deleted_list = db.session.get(List, lst.id)
assert deleted_list is not None
assert deleted_list.deleted_at is not None
# Verify cards are also soft-deleted
deleted_card1 = db.session.get(Card, card1.id)
deleted_card2 = db.session.get(Card, card2.id)
assert deleted_card1 is not None
assert deleted_card1.deleted_at is not None
assert deleted_card2 is not None
assert deleted_card2.deleted_at is not None
def test_soft_delete_multiple_resources_in_sequence(
self, client, db_session, regular_user, auth_headers
):
"""Test deleting multiple resources in sequence and verifying soft delete"""
# Create board with lists, cards, comments, checklists, labels
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
comment = Comment(text="Test comment", card_id=card.id, user_id=regular_user.id)
db_session.add(comment)
db_session.flush()
label = Label(name="Urgent", color="red", board_id=board.id)
db_session.add(label)
db_session.flush()
card_label = CardLabel(card_id=card.id, label_id=label.id)
db_session.add(card_label)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.flush()
item = CheckItem(
name="Task", checklist_id=checklist.id, pos=0, state="incomplete"
)
db_session.add(item)
db_session.commit()
# Delete multiple resources in sequence
client.delete(f"/api/comments/{comment.id}", headers=auth_headers)
client.delete(f"/api/check-items/{item.id}", headers=auth_headers)
client.delete(f"/api/cards/{card.id}/labels/{label.id}", headers=auth_headers)
# Verify all are marked as deleted in database
deleted_comment = db.session.get(Comment, comment.id)
deleted_item = db.session.get(CheckItem, item.id)
deleted_card_label = (
db_session.query(CardLabel)
.filter_by(card_id=card.id, label_id=label.id)
.first()
)
assert deleted_comment is not None
assert deleted_comment.deleted_at is not None
assert deleted_item is not None
assert deleted_item.deleted_at is not None
assert deleted_card_label is not None
assert deleted_card_label.deleted_at is not None
def test_soft_delete_record_still_exists(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted records still exist in the database"""
# Create board
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.commit()
# Delete the board
response = client.delete(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
# Verify record still exists (not hard deleted)
deleted_board = db.session.get(Board, board.id)
assert deleted_board is not None
assert deleted_board.id == board.id
assert deleted_board.name == "Test Board"
def test_convert_check_item_to_card_deletes_check_item(
self, client, db_session, regular_user, auth_headers
):
"""Test that converting a check item to a card soft-deletes the check item"""
# Create board, list, card, checklist and check item
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Parent Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.flush()
item = CheckItem(
name="Task to convert", checklist_id=checklist.id, pos=0, state="incomplete"
)
db_session.add(item)
db_session.commit()
# Convert check item to card
response = client.post(
f"/api/cards/{card.id}/checklist-items/{item.id}/convert-to-card",
headers=auth_headers,
json={"list_id": lst.id},
)
assert response.status_code == 201
# Verify check item is soft-deleted
deleted_item = db.session.get(CheckItem, item.id)
assert deleted_item is not None
assert deleted_item.deleted_at is not None

View file

@ -518,12 +518,13 @@ class TestWikiRoutes:
# Verify wiki is deleted # Verify wiki is deleted
deleted_wiki = db.session.get(Wiki, wiki_id) deleted_wiki = db.session.get(Wiki, wiki_id)
assert deleted_wiki is None assert deleted_wiki is not None
assert deleted_wiki.status == "deleted"
def test_delete_wiki_with_links( def test_delete_wiki_with_links(
self, client, db_session, auth_headers, test_board, regular_user, test_card self, client, db_session, auth_headers, test_board, regular_user, test_card
): ):
"""Test deleting wiki removes entity links""" """Test deleting wiki (soft delete) preserves entity links"""
wiki = Wiki( wiki = Wiki(
name="Wiki with Links", name="Wiki with Links",
board_id=test_board.id, board_id=test_board.id,
@ -545,13 +546,18 @@ class TestWikiRoutes:
assert response.status_code == 200 assert response.status_code == 200
# Verify links are deleted (cascade) # Verify wiki is soft-deleted
deleted_wiki = db.session.get(Wiki, wiki.id)
assert deleted_wiki is not None
assert deleted_wiki.status == "deleted"
# Verify links are preserved (not deleted with soft delete)
from sqlalchemy import select from sqlalchemy import select
links = db.session.scalars( links = db.session.scalars(
select(wiki_entity_links).where(wiki_entity_links.c.wiki_id == wiki.id) select(wiki_entity_links).where(wiki_entity_links.c.wiki_id == wiki.id)
).all() ).all()
assert len(links) == 0 assert len(links) == 1 # Links are preserved
def test_delete_wiki_not_found(self, client, db_session, auth_headers): def test_delete_wiki_not_found(self, client, db_session, auth_headers):
"""Test deleting non-existent wiki""" """Test deleting non-existent wiki"""

View file

@ -0,0 +1,113 @@
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,
onCreateLinkedCard,
onLinkExistingCard,
}: CardActionDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
const handleEdit = () => {
setIsOpen(false);
onEdit();
};
const handleDelete = () => {
setIsOpen(false);
onDelete();
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="text-gray-400 hover:text-white hover:bg-gray-700 p-2 rounded-lg transition-colors"
title="Card actions"
aria-label="Card actions"
aria-expanded={isOpen}
>
<VerticalEllipsisIcon />
</button>
{isOpen && (
<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}
className="w-full flex items-center gap-2 px-4 py-2 text-left text-gray-300 hover:text-white hover:bg-gray-700 transition-colors"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
Edit Card
</button>
{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"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
Delete Card
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -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">
{' '}
&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 <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"

View file

@ -32,7 +32,7 @@ export function CardEpics({ cardEpics, boardId, cardId, refetchCard }: CardEpics
return; return;
} }
const success = await removeEpic(epicId, epicName); const success = await removeEpic(epicId);
if (success) { if (success) {
await refetchCard(); await refetchCard();
} }

View file

@ -0,0 +1,129 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { CardLinksResponse } from '../types/kanban';
import LinkIcon from './icons/LinkIcon';
import UnlinkIcon from './icons/UnlinkIcon';
import { UnlinkCardModal } from './UnlinkCardModal';
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;
const [pendingUnlink, setPendingUnlink] = useState<{
id: number;
cardName: string;
} | null>(null);
const handleConfirmUnlink = () => {
if (pendingUnlink) {
onUnlink(pendingUnlink.id);
setPendingUnlink(null);
}
};
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={() =>
setPendingUnlink({
id: link.id,
cardName: link.card.name,
})
}
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>
)}
{pendingUnlink && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<UnlinkCardModal
cardName={pendingUnlink.cardName}
onUnlink={handleConfirmUnlink}
onClose={() => setPendingUnlink(null)}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,109 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import CloseIcon from './icons/CloseIcon';
const linkedCardSchema = z.object({
name: z
.string()
.min(1, 'Card name is required')
.max(100, 'Card name must be less than 100 characters'),
description: z.string().max(2000, 'Description must be less than 2000 characters').optional(),
});
type LinkedCardFormData = z.infer<typeof linkedCardSchema>;
interface CreateLinkedCardModalProps {
parentCardName: string;
onClose: () => void;
onSubmit: (name: string, description: string) => void;
}
export function CreateLinkedCardModal({
parentCardName,
onClose,
onSubmit,
}: CreateLinkedCardModalProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LinkedCardFormData>({
resolver: zodResolver(linkedCardSchema),
});
const onFormSubmit = async (data: LinkedCardFormData) => {
await onSubmit(data.name.trim(), data.description?.trim() ?? '');
};
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(onFormSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-1">
Card Name <span className="text-red-400">*</span>
</label>
<input
id="name"
type="text"
{...register('name')}
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
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-1">
Description
</label>
<textarea
id="description"
{...register('description')}
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..."
/>
{errors.description && (
<p className="mt-1 text-sm text-red-400">{errors.description.message}</p>
)}
</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={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,101 @@
import { useState } from 'react';
import CloseIcon from './icons/CloseIcon';
import { useLinkExistingCard } from '../hooks/useLinkExistingCard';
interface LinkExistingCardModalProps {
boardId: number;
currentCardId: number;
onClose: () => void;
onLinked: () => void;
}
export function LinkExistingCardModal({
boardId,
currentCardId,
onClose,
onLinked,
}: LinkExistingCardModalProps) {
const [search, setSearch] = useState('');
const [selectedCardId, setSelectedCardId] = useState<number | null>(null);
const { cards, isSubmitting, linkCard } = useLinkExistingCard(boardId, currentCardId);
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;
const success = await linkCard(selectedCardId);
if (success) {
onLinked();
onClose();
}
};
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,40 @@
import UnlinkIcon from './icons/UnlinkIcon';
interface UnlinkCardModalProps {
cardName: string;
onUnlink: () => void;
onClose: () => void;
}
export function UnlinkCardModal({ cardName, onUnlink, onClose }: UnlinkCardModalProps) {
return (
<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-orange-600 rounded-full flex items-center justify-center">
<span className="w-5 h-5">
<UnlinkIcon />
</span>
</span>
<h3 className="text-xl font-bold text-white">Unlink Card</h3>
</div>
<p className="text-gray-300 mb-6">
Are you sure you want to unlink{' '}
<span className="text-white font-semibold">&quot;{cardName}&quot;</span> from this card?
</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={onUnlink}
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors"
>
Unlink
</button>
</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 { 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">

View file

@ -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`);

View file

@ -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,
}; };
} }

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

@ -0,0 +1,16 @@
import { useEffect } from 'react';
const DEFAULT_TITLE = 'Taskboard';
export function useDocumentTitle(title?: string) {
useEffect(() => {
if (title) {
document.title = `${title} | ${DEFAULT_TITLE}`;
} else {
document.title = DEFAULT_TITLE;
}
return () => {
document.title = DEFAULT_TITLE;
};
}, [title]);
}

View file

@ -0,0 +1,109 @@
import { useState, useRef, useEffect } from 'react';
export function useInlineEditing(
card: { name: string; description?: string | null } | null,
onSave: (name: string, description: string) => Promise<boolean>
) {
// Inline editing state
const [isEditingName, setIsEditingName] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [editedName, setEditedName] = useState('');
const [editedDescription, setEditedDescription] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const descriptionInputRef = useRef<HTMLTextAreaElement>(null);
// Focus input when editing starts
useEffect(() => {
if (isEditingName && nameInputRef.current) {
nameInputRef.current.focus();
}
}, [isEditingName]);
useEffect(() => {
if (isEditingDescription && descriptionInputRef.current) {
descriptionInputRef.current.focus();
}
}, [isEditingDescription]);
// Start editing name
const handleStartEditingName = () => {
if (!card) return;
setEditedName(card.name);
setIsEditingName(true);
};
// Save name
const handleSaveName = async () => {
if (!card) return;
const success = await onSave(editedName, card.description || '');
if (success) {
setIsEditingName(false);
}
};
// Cancel editing name
const handleCancelEditingName = () => {
setIsEditingName(false);
setEditedName(card?.name || '');
};
// Start editing description
const handleStartEditingDescription = () => {
if (!card) return;
setEditedDescription(card.description || '');
setIsEditingDescription(true);
};
// Save description
const handleSaveDescription = async () => {
if (!card) return;
const success = await onSave(card.name, editedDescription);
if (success) {
setIsEditingDescription(false);
}
};
// Cancel editing description
const handleCancelEditingDescription = () => {
setIsEditingDescription(false);
setEditedDescription(card?.description || '');
};
// Handle key press for name input
const handleNameKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSaveName();
} else if (e.key === 'Escape') {
handleCancelEditingName();
}
};
// Handle key press for description input
const handleDescriptionKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancelEditingDescription();
}
};
return {
// State
isEditingName,
isEditingDescription,
editedName,
editedDescription,
nameInputRef,
descriptionInputRef,
// Handlers
handleStartEditingName,
handleSaveName,
handleCancelEditingName,
handleStartEditingDescription,
handleSaveDescription,
handleCancelEditingDescription,
handleNameKeyPress,
handleDescriptionKeyPress,
// Setters
setEditedName,
setEditedDescription,
};
}

View file

@ -0,0 +1,68 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Card } from '../types/kanban';
export function useLinkExistingCard(boardId: number, currentCardId: number) {
const [cards, setCards] = useState<Card[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const { getBoard, createCardLink } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchCards = useCallback(async () => {
try {
const board = await getBoard(boardId);
const allCards: Card[] = [];
for (const list of board.lists) {
allCards.push(...list.cards);
}
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,
});
}
}, [getBoard, boardId, currentCardId, addNotification]);
useEffect(() => {
fetchCards();
}, [fetchCards]);
const linkCard = async (targetCardId: number): Promise<boolean> => {
setIsSubmitting(true);
try {
await withLoader(() => createCardLink(currentCardId, targetCardId), 'Linking card...');
addNotification({
type: 'success',
title: 'Card Linked',
message: 'Card linked successfully.',
duration: 3000,
});
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to link card';
addNotification({
type: 'error',
title: 'Error',
message: msg,
duration: 5000,
});
return false;
} finally {
setIsSubmitting(false);
}
};
return {
cards,
isSubmitting,
linkCard,
};
}

View file

@ -1,5 +1,6 @@
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useBoard } from '../hooks/useBoard'; import { useBoard } from '../hooks/useBoard';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
import { useCardMutations } from '../hooks/useCardMutations'; import { useCardMutations } from '../hooks/useCardMutations';
import { useListMutations } from '../hooks/useListMutations'; import { useListMutations } from '../hooks/useListMutations';
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn'; import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
@ -25,6 +26,7 @@ import { WidePageLayout } from '@/components/WidePageLayout';
export function BoardDetail() { export function BoardDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { board, fetchBoard } = useBoard(parseInt(id || '0')); const { board, fetchBoard } = useBoard(parseInt(id || '0'));
useDocumentTitle(board?.name);
const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard); const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard);
const { createList, updateList, deleteList } = useListMutations(parseInt(id || '0'), fetchBoard); const { createList, updateList, deleteList } = useListMutations(parseInt(id || '0'), fetchBoard);
const { openModal } = useModal(); const { openModal } = useModal();

View file

@ -1,39 +1,57 @@
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 { useDocumentTitle } from '../hooks/useDocumentTitle';
import { useCardDetailMutations } from '../hooks/useCardDetailMutations'; import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
import { useChecklistMutations } from '../hooks/useChecklistMutations'; import { useChecklistMutations } from '../hooks/useChecklistMutations';
import { useLabels } from '../hooks/useLabels'; import { useLabels } from '../hooks/useLabels';
import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations'; import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations';
import { useModal } from '../context/modals/useModal'; import { useModal } from '../context/modals/useModal';
import { useInlineEditing } from '../hooks/useInlineEditing';
import { 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 { EditCardModal } from '../components/EditCardModal'; import { CardLinks } from '../components/CardLinks';
import { DeleteCardModal } from '../components/DeleteCardModal'; import { DeleteCardModal } from '../components/DeleteCardModal';
import Trash2Icon from '../components/icons/Trash2Icon'; 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 Edit2Icon from '../components/icons/Edit2Icon';
import { NarrowPageLayout } from '@/components/NarrowPageLayout'; import { NarrowPageLayout } from '@/components/NarrowPageLayout';
import { formatDateTime } from '../utils/dateFormat'; import { formatDateTime } from '../utils/dateFormat';
import Edit2Icon from '../components/icons/Edit2Icon';
export function CardDetail() { export function CardDetail() {
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>(); const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { card, fetchCard } = useCard(parseInt(cardId || '0')); const { card, fetchCard } = useCard(parseInt(cardId || '0'));
useDocumentTitle(card?.name);
const { const {
updateCardNameAndDescription, updateCardNameAndDescription,
deleteCardWithConfirmation, deleteCardWithConfirmation,
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
const inlineEditing = useInlineEditing(card, updateCardNameAndDescription);
// Labels functionality // Labels functionality
const { labels, refetch: refetchLabels } = useLabels(parseInt(boardId || '0')); const { labels, refetch: refetchLabels } = useLabels(parseInt(boardId || '0'));
const { addLabel } = useLabelMutations(parseInt(boardId || '0'), refetchLabels); const { addLabel } = useLabelMutations(parseInt(boardId || '0'), refetchLabels);
@ -42,20 +60,6 @@ export function CardDetail() {
fetchCard fetchCard
); );
const handleEditCard = () => {
if (!card) return;
openModal((props) => (
<EditCardModal
card={card}
onSave={async (name, description) => {
return await updateCardNameAndDescription(name, description);
}}
onClose={props.onClose}
/>
));
};
const handleDeleteCard = () => { const handleDeleteCard = () => {
if (!card) return; if (!card) return;
@ -90,31 +94,56 @@ export function CardDetail() {
</span> </span>
Back to Board Back to Board
</Link> </Link>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-2 mt-2">
<h1 className="text-3xl font-bold text-white">{card.name}</h1> {inlineEditing.isEditingName ? (
<button <div className="flex items-center gap-2 flex-1">
onClick={handleEditCard} <input
className="text-gray-400 hover:text-white transition-colors" ref={inlineEditing.nameInputRef}
title="Edit card" type="text"
> value={inlineEditing.editedName}
<span className="w-5 h-5"> onChange={(e) => inlineEditing.setEditedName(e.target.value)}
<Edit2Icon /> onKeyDown={inlineEditing.handleNameKeyPress}
</span> className="flex-1 text-3xl font-bold text-white bg-gray-800 border-2 border-blue-500 rounded-lg px-3 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
</button> />
<button
onClick={inlineEditing.handleSaveName}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg transition-colors"
>
Save
</button>
<button
onClick={inlineEditing.handleCancelEditingName}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded-lg transition-colors"
>
Cancel
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold text-white">{card.name}</h1>
<button
onClick={inlineEditing.handleStartEditingName}
className="text-gray-400 hover:text-white transition-colors"
title="Edit card name"
>
<span className="w-5 h-5">
<Edit2Icon />
</span>
</button>
</div>
)}
</div> </div>
<p className="text-gray-400 text-sm mt-1"> <p className="text-gray-400 text-sm mt-1">
In list Created {formatDateTime(card.created_at)} In list <span className="text-white font-medium">{card.list_name}</span> Created{' '}
{formatDateTime(card.created_at)}
</p> </p>
</div> </div>
<button <CardActionDropdown
onClick={handleDeleteCard} onEdit={inlineEditing.handleStartEditingName}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center gap-2" onDelete={handleDeleteCard}
> onCreateLinkedCard={() => setShowCreateLinkedModal(true)}
<span className="w-4 h-4"> onLinkExistingCard={() => setShowLinkExistingModal(true)}
<Trash2Icon /> />
</span>
Delete Card
</button>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@ -123,16 +152,45 @@ export function CardDetail() {
<div className="bg-gray-800 rounded-lg p-6"> <div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">Description</h2> <h2 className="text-xl font-bold text-white flex items-center gap-2">Description</h2>
<button {!inlineEditing.isEditingDescription && (
onClick={handleEditCard} <button
className="text-blue-400 hover:text-blue-300 text-sm font-medium" onClick={inlineEditing.handleStartEditingDescription}
> className="text-blue-400 hover:text-blue-300 text-sm font-medium"
Edit >
</button> Edit
</button>
)}
</div> </div>
<p className="text-gray-300 whitespace-pre-wrap"> {inlineEditing.isEditingDescription ? (
{card.description || 'No description added yet.'} <div className="space-y-3">
</p> <textarea
ref={inlineEditing.descriptionInputRef}
value={inlineEditing.editedDescription}
onChange={(e) => inlineEditing.setEditedDescription(e.target.value)}
onKeyDown={inlineEditing.handleDescriptionKeyPress}
className="w-full min-h-[200px] text-gray-300 bg-gray-700 border-2 border-blue-500 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y"
placeholder="Add a description..."
/>
<div className="flex gap-2">
<button
onClick={inlineEditing.handleSaveDescription}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors"
>
Save
</button>
<button
onClick={inlineEditing.handleCancelEditingDescription}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<p className="text-gray-300 whitespace-pre-wrap">
{card.description || 'No description added yet.'}
</p>
)}
</div> </div>
{/* Labels Section */} {/* Labels Section */}
@ -154,6 +212,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 || []}
@ -164,6 +229,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} />
@ -179,6 +253,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>
); );
} }

View file

@ -3,6 +3,7 @@ import { useState } from 'react';
import { WidePageLayout } from '../components/WidePageLayout'; import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent'; import RichTextContent from '../components/RichTextContent';
import useEpicDetail from '../hooks/useEpicDetail'; import useEpicDetail from '../hooks/useEpicDetail';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
import Edit2Icon from '../components/icons/Edit2Icon'; import Edit2Icon from '../components/icons/Edit2Icon';
import Trash2Icon from '../components/icons/Trash2Icon'; import Trash2Icon from '../components/icons/Trash2Icon';
import ChevronRightIcon from '../components/icons/ChevronRightIcon'; import ChevronRightIcon from '../components/icons/ChevronRightIcon';
@ -12,6 +13,7 @@ export function EpicDetail() {
const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>(); const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { epic, deleteEpic } = useEpicDetail(epicId || '0'); const { epic, deleteEpic } = useEpicDetail(epicId || '0');
useDocumentTitle(epic?.name);
const [isContentExpanded, setIsContentExpanded] = useState(false); const [isContentExpanded, setIsContentExpanded] = useState(false);
const handleDelete = async () => { const handleDelete = async () => {

View file

@ -3,6 +3,7 @@ import { useState } from 'react';
import { WidePageLayout } from '../components/WidePageLayout'; import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent'; import RichTextContent from '../components/RichTextContent';
import useWikiDetail from '../hooks/useWikiDetail'; import useWikiDetail from '../hooks/useWikiDetail';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
import Edit2Icon from '../components/icons/Edit2Icon'; import Edit2Icon from '../components/icons/Edit2Icon';
import Trash2Icon from '../components/icons/Trash2Icon'; import Trash2Icon from '../components/icons/Trash2Icon';
import ChevronRightIcon from '../components/icons/ChevronRightIcon'; import ChevronRightIcon from '../components/icons/ChevronRightIcon';
@ -12,6 +13,7 @@ export function WikiDetail() {
const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>(); const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { wiki, deleteWiki } = useWikiDetail(wikiId || '0'); const { wiki, deleteWiki } = useWikiDetail(wikiId || '0');
useDocumentTitle(wiki?.name);
const [isContentExpanded, setIsContentExpanded] = useState(false); const [isContentExpanded, setIsContentExpanded] = useState(false);
const handleDelete = async () => { const handleDelete = async () => {

View file

@ -1,12 +1,6 @@
// User types // User types
export * from './user'; export * from './user';
// Product types
export * from './product';
// Order types
export * from './order';
// API types // API types
export * from './api'; export * from './api';

View file

@ -73,6 +73,7 @@ export interface Card {
id_short: number; id_short: number;
board_id: number; board_id: number;
list_id: number; list_id: number;
list_name: string;
epic_id: number | null; epic_id: number | null;
date_last_activity: string; date_last_activity: string;
created_at: string; created_at: string;
@ -80,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 {
@ -192,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;