Compare commits
5 commits
2f53b8f4bb
...
5b95262681
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b95262681 | |||
| 8e138689cf | |||
| f407285e60 | |||
| 543c28984f | |||
| f59670397a |
67 changed files with 3041 additions and 190 deletions
29
.github/workflows/backend.yml
vendored
29
.github/workflows/backend.yml
vendored
|
|
@ -15,6 +15,8 @@ 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:
|
||||||
|
|
@ -22,7 +24,7 @@ jobs:
|
||||||
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
|
||||||
|
|
@ -46,6 +48,27 @@ jobs:
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
137
backend/app/models/base.py
Normal 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
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
69
backend/app/models/card_link.py
Normal file
69
backend/app/models/card_link.py
Normal 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"),
|
||||||
|
)
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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}>"
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
278
backend/app/routes/kanban/card_links.py
Normal file
278
backend/app/routes/kanban/card_links.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ###
|
||||||
|
|
@ -18,11 +18,14 @@ depends_on = None
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
# Drop order_items first (has FK to products)
|
||||||
|
op.drop_table('order_items')
|
||||||
|
|
||||||
with op.batch_alter_table('products', schema=None) as batch_op:
|
with op.batch_alter_table('products', schema=None) as batch_op:
|
||||||
batch_op.drop_index(batch_op.f('ix_products_name'))
|
batch_op.drop_index(batch_op.f('ix_products_name'))
|
||||||
|
|
||||||
op.drop_table('products')
|
op.drop_table('products')
|
||||||
op.drop_table('order_items')
|
|
||||||
with op.batch_alter_table('orders', schema=None) as batch_op:
|
with op.batch_alter_table('orders', schema=None) as batch_op:
|
||||||
batch_op.drop_index(batch_op.f('ix_orders_status'))
|
batch_op.drop_index(batch_op.f('ix_orders_status'))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""add_card_links_table
|
||||||
|
|
||||||
|
Revision ID: bf430156bcf2
|
||||||
|
Revises: a9709e7ed22d
|
||||||
|
Create Date: 2026-04-30 19:37:25.884514
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'bf430156bcf2'
|
||||||
|
down_revision = 'a9709e7ed22d'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('card_links',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('parent_card_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('child_card_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['child_card_id'], ['cards.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['parent_card_id'], ['cards.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('parent_card_id', 'child_card_id', name='unique_card_link')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('card_links', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_card_links_child_card_id'), ['child_card_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_card_links_created_by'), ['created_by'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_card_links_parent_card_id'), ['parent_card_id'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('card_links', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_card_links_parent_card_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_card_links_created_by'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_card_links_child_card_id'))
|
||||||
|
|
||||||
|
op.drop_table('card_links')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
496
backend/tests/routes/test_soft_delete_integration.py
Normal file
496
backend/tests/routes/test_soft_delete_integration.py
Normal 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
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
113
frontend/src/components/CardActionDropdown.tsx
Normal file
113
frontend/src/components/CardActionDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import CheckSquareIcon from './icons/CheckSquareIcon';
|
||||||
import Trash2Icon from './icons/Trash2Icon';
|
import Trash2Icon from './icons/Trash2Icon';
|
||||||
import Edit2Icon from './icons/Edit2Icon';
|
import Edit2Icon from './icons/Edit2Icon';
|
||||||
import PlusIcon from './icons/PlusIcon';
|
import PlusIcon from './icons/PlusIcon';
|
||||||
|
import MonitorIcon from './icons/MonitorIcon';
|
||||||
import { useModal } from '../context/modals/useModal';
|
import { useModal } from '../context/modals/useModal';
|
||||||
import { CreateChecklistModal } from './CreateChecklistModal';
|
import { CreateChecklistModal } from './CreateChecklistModal';
|
||||||
import { DeleteChecklistModal } from './DeleteChecklistModal';
|
import { DeleteChecklistModal } from './DeleteChecklistModal';
|
||||||
|
|
@ -27,6 +28,7 @@ interface CardChecklistsProps {
|
||||||
state: 'incomplete' | 'complete'
|
state: 'incomplete' | 'complete'
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
removeCheckItem: (itemId: number) => Promise<boolean>;
|
removeCheckItem: (itemId: number) => Promise<boolean>;
|
||||||
|
onConvertToCard?: (itemName: string, itemId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardChecklists({
|
export function CardChecklists({
|
||||||
|
|
@ -37,6 +39,7 @@ export function CardChecklists({
|
||||||
toggleCheckItem,
|
toggleCheckItem,
|
||||||
editCheckItem,
|
editCheckItem,
|
||||||
removeCheckItem,
|
removeCheckItem,
|
||||||
|
onConvertToCard,
|
||||||
}: CardChecklistsProps) {
|
}: CardChecklistsProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
|
|
@ -152,7 +155,7 @@ export function CardChecklists({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{checklist.items && checklist.items.length > 0 ? (
|
{checklist.items && checklist.items.length > 0 ? (
|
||||||
checklist.items.map((item: any) => (
|
checklist.items.map((item: any, itemIndex: number) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center gap-3 p-2 bg-gray-700 rounded group hover:bg-gray-600 transition-colors"
|
className="flex items-center gap-3 p-2 bg-gray-700 rounded group hover:bg-gray-600 transition-colors"
|
||||||
|
|
@ -167,9 +170,62 @@ export function CardChecklists({
|
||||||
onClick={() => handleToggleCheckItem(item)}
|
onClick={() => handleToggleCheckItem(item)}
|
||||||
className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
|
className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
|
||||||
>
|
>
|
||||||
|
<span className="text-gray-500 mr-1">{itemIndex + 1}.</span>
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{onConvertToCard && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
openModal((modalProps) => (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center">
|
||||||
|
<span className="w-5 h-5">
|
||||||
|
<MonitorIcon />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl font-bold text-white">
|
||||||
|
Convert to Card
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300 mb-6">
|
||||||
|
Convert
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{' '}
|
||||||
|
"{item.name}"{' '}
|
||||||
|
</span>
|
||||||
|
into a new linked card? The checklist item will be removed and
|
||||||
|
a new card will be created.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={modalProps.onClose}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
modalProps.onClose();
|
||||||
|
onConvertToCard(item.name, item.id);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Convert to Card
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-blue-400 transition-colors p-1"
|
||||||
|
title="Convert to card"
|
||||||
|
>
|
||||||
|
<span className="w-3.5 h-3.5">
|
||||||
|
<MonitorIcon />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditCheckItem(item)}
|
onClick={() => handleEditCheckItem(item)}
|
||||||
className="text-gray-400 hover:text-white transition-colors p-1"
|
className="text-gray-400 hover:text-white transition-colors p-1"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
frontend/src/components/CardLinks.tsx
Normal file
129
frontend/src/components/CardLinks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
frontend/src/components/CreateLinkedCardModal.tsx
Normal file
109
frontend/src/components/CreateLinkedCardModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
frontend/src/components/LinkExistingCardModal.tsx
Normal file
101
frontend/src/components/LinkExistingCardModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/UnlinkCardModal.tsx
Normal file
40
frontend/src/components/UnlinkCardModal.tsx
Normal 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">"{cardName}"</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/components/icons/ChevronLeftIcon.tsx
Normal file
17
frontend/src/components/icons/ChevronLeftIcon.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
const ChevronLeftIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ChevronLeftIcon;
|
||||||
18
frontend/src/components/icons/LinkIcon.tsx
Normal file
18
frontend/src/components/icons/LinkIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
const LinkIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default LinkIcon;
|
||||||
19
frontend/src/components/icons/MonitorIcon.tsx
Normal file
19
frontend/src/components/icons/MonitorIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
const MonitorIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MonitorIcon;
|
||||||
19
frontend/src/components/icons/VerticalEllipsisIcon.tsx
Normal file
19
frontend/src/components/icons/VerticalEllipsisIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
const VerticalEllipsisIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="1"></circle>
|
||||||
|
<circle cx="12" cy="5" r="1"></circle>
|
||||||
|
<circle cx="12" cy="19" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default VerticalEllipsisIcon;
|
||||||
|
|
@ -3,6 +3,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Card as CardType } from '../../types/kanban';
|
import { Card as CardType } from '../../types/kanban';
|
||||||
import MessageSquareIcon from '../icons/MessageSquareIcon';
|
import MessageSquareIcon from '../icons/MessageSquareIcon';
|
||||||
import CheckSquareIcon from '../icons/CheckSquareIcon';
|
import CheckSquareIcon from '../icons/CheckSquareIcon';
|
||||||
|
import ChevronLeftIcon from '../icons/ChevronLeftIcon';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
interface KanbanCardProps {
|
interface KanbanCardProps {
|
||||||
|
|
@ -47,6 +48,10 @@ export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
|
||||||
const epic = (card as any).epic;
|
const epic = (card as any).epic;
|
||||||
const hasEpic = epic !== null && epic !== undefined;
|
const hasEpic = epic !== null && epic !== undefined;
|
||||||
|
|
||||||
|
// Get parent card name
|
||||||
|
const parentCardName = card.parent_card_name;
|
||||||
|
const hasParent = !!parentCardName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
|
|
@ -61,6 +66,18 @@ export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
|
||||||
}}
|
}}
|
||||||
className="bg-gray-700 rounded-lg p-4 mb-3 cursor-pointer hover:bg-gray-600 transition-colors border border-gray-600 shadow-sm"
|
className="bg-gray-700 rounded-lg p-4 mb-3 cursor-pointer hover:bg-gray-600 transition-colors border border-gray-600 shadow-sm"
|
||||||
>
|
>
|
||||||
|
{/* Parent Card Indicator */}
|
||||||
|
{hasParent && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-purple-900/40 text-purple-300 border border-purple-700/50">
|
||||||
|
<span className="w-2.5 h-2.5">
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</span>
|
||||||
|
{parentCardName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Epic Badge */}
|
{/* Epic Badge */}
|
||||||
{hasEpic && (
|
{hasEpic && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,34 @@ export function useApi() {
|
||||||
await api.delete(`/cards/${cardId}/epics/${epicId}`);
|
await api.delete(`/cards/${cardId}/epics/${epicId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Card Links
|
||||||
|
getCardLinks: async (cardId: number): Promise<any> => {
|
||||||
|
const response = await api.get(`/cards/${cardId}/links`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
createCardLink: async (parentCardId: number, childCardId: number): Promise<any> => {
|
||||||
|
const response = await api.post(`/cards/${parentCardId}/links`, {
|
||||||
|
child_card_id: childCardId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
createLinkedCard: async (
|
||||||
|
parentCardId: number,
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
list_id: number;
|
||||||
|
description?: string;
|
||||||
|
copy_labels?: boolean;
|
||||||
|
copy_epics?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<any> => {
|
||||||
|
const response = await api.post(`/cards/${parentCardId}/linked-cards`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
deleteCardLink: async (linkId: number): Promise<void> => {
|
||||||
|
await api.delete(`/card-links/${linkId}`);
|
||||||
|
},
|
||||||
|
|
||||||
// Wikis
|
// Wikis
|
||||||
getWikis: async (boardId: number): Promise<any> => {
|
getWikis: async (boardId: number): Promise<any> => {
|
||||||
const response = await api.get(`/boards/${boardId}/wikis`);
|
const response = await api.get(`/boards/${boardId}/wikis`);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { useLoader } from '../context/loaders/useLoader';
|
||||||
import { useToast } from '../context/toasts/useToast';
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
|
||||||
export function useCardDetailMutations(cardId: number, currentCard: any, onUpdate: () => void) {
|
export function useCardDetailMutations(cardId: number, currentCard: any, onUpdate: () => void) {
|
||||||
const { updateCard, deleteCard, createComment, updateComment, deleteComment } = useApi();
|
const { updateCard, deleteCard, createComment, updateComment, deleteComment, createLinkedCard } =
|
||||||
|
useApi();
|
||||||
const { withLoader } = useLoader();
|
const { withLoader } = useLoader();
|
||||||
const { addNotification } = useToast();
|
const { addNotification } = useToast();
|
||||||
|
|
||||||
|
|
@ -127,11 +128,87 @@ export function useCardDetailMutations(cardId: number, currentCard: any, onUpdat
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createLinkedCardFromModal = async (
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
listId: number,
|
||||||
|
refreshLinks: () => Promise<void>
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await withLoader(
|
||||||
|
() =>
|
||||||
|
createLinkedCard(cardId, {
|
||||||
|
name,
|
||||||
|
list_id: listId,
|
||||||
|
description,
|
||||||
|
copy_labels: true,
|
||||||
|
copy_epics: true,
|
||||||
|
}),
|
||||||
|
'Creating linked card...'
|
||||||
|
);
|
||||||
|
await refreshLinks();
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Linked Card Created',
|
||||||
|
message: `"${name}" created and linked.`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to create linked card.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertCheckItemToCard = async (
|
||||||
|
itemName: string,
|
||||||
|
itemId: number,
|
||||||
|
listId: number,
|
||||||
|
removeCheckItem: (itemId: number) => Promise<boolean>,
|
||||||
|
refreshLinks: () => Promise<void>
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await withLoader(
|
||||||
|
() =>
|
||||||
|
createLinkedCard(cardId, {
|
||||||
|
name: itemName,
|
||||||
|
list_id: listId,
|
||||||
|
}),
|
||||||
|
'Converting to card...'
|
||||||
|
);
|
||||||
|
await removeCheckItem(itemId);
|
||||||
|
onUpdate();
|
||||||
|
await refreshLinks();
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Converted to Card',
|
||||||
|
message: `"${itemName}" is now a linked card.`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to convert item to card.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateCardNameAndDescription,
|
updateCardNameAndDescription,
|
||||||
deleteCardWithConfirmation,
|
deleteCardWithConfirmation,
|
||||||
addComment,
|
addComment,
|
||||||
editComment,
|
editComment,
|
||||||
deleteCommentWithConfirmation,
|
deleteCommentWithConfirmation,
|
||||||
|
createLinkedCardFromModal,
|
||||||
|
convertCheckItemToCard,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
frontend/src/hooks/useCardLinks.ts
Normal file
105
frontend/src/hooks/useCardLinks.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
import { useLoader } from '../context/loaders/useLoader';
|
||||||
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
import type { CardLinksResponse } from '../types/kanban';
|
||||||
|
|
||||||
|
export function useCardLinks(cardId: number) {
|
||||||
|
const [links, setLinks] = useState<CardLinksResponse>({
|
||||||
|
child_cards: [],
|
||||||
|
parent_cards: [],
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { getCardLinks, createCardLink, deleteCardLink } = useApi();
|
||||||
|
const { withLoader } = useLoader();
|
||||||
|
const { addNotification } = useToast();
|
||||||
|
|
||||||
|
const fetchLinks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await getCardLinks(cardId);
|
||||||
|
setLinks(data);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load card links';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Loading Links',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return { child_cards: [], parent_cards: [] };
|
||||||
|
}
|
||||||
|
}, [getCardLinks, cardId, addNotification]);
|
||||||
|
|
||||||
|
const linkCard = useCallback(
|
||||||
|
async (childCardId: number) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await withLoader(() => createCardLink(cardId, childCardId), 'Linking card...');
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Card Linked',
|
||||||
|
message: 'Card linked successfully.',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
await fetchLinks();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to link card';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Linking Card',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cardId, createCardLink, withLoader, addNotification, fetchLinks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlinkCard = useCallback(
|
||||||
|
async (linkId: number) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await withLoader(() => deleteCardLink(linkId), 'Unlinking card...');
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Card Unlinked',
|
||||||
|
message: 'Card unlinked successfully.',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
await fetchLinks();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to unlink card';
|
||||||
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Unlinking Card',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteCardLink, withLoader, addNotification, fetchLinks]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLinks();
|
||||||
|
}, [fetchLinks]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
links,
|
||||||
|
error,
|
||||||
|
loading: false,
|
||||||
|
fetchLinks,
|
||||||
|
linkCard,
|
||||||
|
unlinkCard,
|
||||||
|
};
|
||||||
|
}
|
||||||
16
frontend/src/hooks/useDocumentTitle.ts
Normal file
16
frontend/src/hooks/useDocumentTitle.ts
Normal 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]);
|
||||||
|
}
|
||||||
109
frontend/src/hooks/useInlineEditing.ts
Normal file
109
frontend/src/hooks/useInlineEditing.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
frontend/src/hooks/useLinkExistingCard.ts
Normal file
68
frontend/src/hooks/useLinkExistingCard.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue