Merge pull request 'fix-epic-clean-up-ui' (#9) from fix-epic-clean-up-ui into main
Reviewed-on: http://192.168.1.135:3000/david/flask_react_monorepo_template/pulls/9
This commit is contained in:
commit
2b8fed9d6e
71 changed files with 3946 additions and 209 deletions
35
.github/workflows/backend.yml
vendored
35
.github/workflows/backend.yml
vendored
|
|
@ -15,14 +15,16 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
backend-test:
|
backend-test:
|
||||||
runs-on: [docker]
|
runs-on: [docker]
|
||||||
|
env:
|
||||||
|
UNIQUE_DB: test_db_${{ github.run_id }}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: test
|
POSTGRES_USER: test
|
||||||
POSTGRES_PASSWORD: test
|
POSTGRES_PASSWORD: test
|
||||||
POSTGRES_DB: test_db
|
POSTGRES_DB: test_db_${{ github.run_id }}_${{ github.run_attempt }}
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
|
|
@ -39,13 +41,34 @@ jobs:
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
run: |
|
run: |
|
||||||
python --version
|
python --version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install --cache-dir /tmp/pip-cache -r requirements/dev.txt
|
pip install --cache-dir /tmp/pip-cache -r requirements/dev.txt
|
||||||
|
|
||||||
|
- name: Create Unique Test Database
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://test:test@postgres:5432/postgres
|
||||||
|
run: |
|
||||||
|
# Install postgresql-client if not present in the alpine image to run psql
|
||||||
|
# Or use python to create the db
|
||||||
|
cd backend
|
||||||
|
python -c "
|
||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
db_url = os.environ['DATABASE_URL']
|
||||||
|
engine = create_engine(db_url)
|
||||||
|
new_db = os.environ['UNIQUE_DB']
|
||||||
|
# Connect to default 'postgres' db to create the new one
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text('COMMIT')) # Close any open transactions
|
||||||
|
conn.execute(text(f'DROP DATABASE IF EXISTS {new_db}'))
|
||||||
|
conn.execute(text(f'CREATE DATABASE {new_db}'))
|
||||||
|
print(f'Created database: {new_db}')
|
||||||
|
"
|
||||||
|
|
||||||
- name: Debug cache
|
- name: Debug cache
|
||||||
run: |
|
run: |
|
||||||
echo "Listing PIP cache files:"
|
echo "Listing PIP cache files:"
|
||||||
|
|
@ -59,8 +82,8 @@ jobs:
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db
|
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
|
||||||
DATABASE_URL: postgresql://test:test@postgres:5432/test_db
|
DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
|
||||||
SECRET_KEY: test-secret-key
|
SECRET_KEY: test-secret-key
|
||||||
JWT_SECRET_KEY: test-jwt-secret
|
JWT_SECRET_KEY: test-jwt-secret
|
||||||
FLASK_ENV: test
|
FLASK_ENV: test
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class EpicCreateRequest(BaseModel):
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=200, description="Epic name")
|
name: str = Field(..., min_length=1, max_length=200, description="Epic name")
|
||||||
description: Optional[str] = Field(None, description="Epic description")
|
description: Optional[str] = Field(None, description="Epic description")
|
||||||
content: Optional[Any] = Field(None, description="Rich text content")
|
content: Optional[Any] = Field(..., min_length=1, description="Rich text content")
|
||||||
color: Optional[str] = Field(None, max_length=7, description="Hex color code")
|
color: Optional[str] = Field(None, max_length=7, description="Hex color code")
|
||||||
pos: Optional[float] = Field(None, description="Position for ordering")
|
pos: Optional[float] = Field(None, description="Position for ordering")
|
||||||
depth_limit: Optional[int] = Field(
|
depth_limit: Optional[int] = Field(
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,9 @@ class TestEpicRoutes:
|
||||||
self, client, db_session, auth_headers, test_board
|
self, client, db_session, auth_headers, test_board
|
||||||
):
|
):
|
||||||
"""Test creating epic with only required fields"""
|
"""Test creating epic with only required fields"""
|
||||||
epic_data = {"name": "Minimal Epic"}
|
|
||||||
|
new_content = [{"type": "heading", "children": [{"text": "Updated Content"}]}]
|
||||||
|
epic_data = {"name": "Minimal Epic", "content": new_content}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/boards/{test_board.id}/epics",
|
f"/api/boards/{test_board.id}/epics",
|
||||||
|
|
@ -113,13 +115,18 @@ class TestEpicRoutes:
|
||||||
):
|
):
|
||||||
"""Test creating epic with parent epic"""
|
"""Test creating epic with parent epic"""
|
||||||
# Create parent epic
|
# Create parent epic
|
||||||
parent_epic = Epic(name="Parent Epic", board_id=test_board.id)
|
parent_epic = Epic(
|
||||||
|
name="Parent Epic",
|
||||||
|
board_id=test_board.id,
|
||||||
|
content=[{"type": "heading", "children": [{"text": "Updated Content"}]}],
|
||||||
|
)
|
||||||
db_session.add(parent_epic)
|
db_session.add(parent_epic)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
epic_data = {
|
epic_data = {
|
||||||
"name": "Child Epic",
|
"name": "Child Epic",
|
||||||
"parent_epic_id": parent_epic.id,
|
"parent_epic_id": parent_epic.id,
|
||||||
|
"content": [{"type": "heading", "children": [{"text": "Updated Content"}]}],
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
|
|
@ -139,8 +146,8 @@ class TestEpicRoutes:
|
||||||
epic_data = {
|
epic_data = {
|
||||||
"name": "Epic with Completed List",
|
"name": "Epic with Completed List",
|
||||||
"completed_list_id": test_list.id,
|
"completed_list_id": test_list.id,
|
||||||
|
"content": [{"type": "heading", "children": [{"text": "Updated Content"}]}],
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/boards/{test_board.id}/epics",
|
f"/api/boards/{test_board.id}/epics",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
|
|
@ -153,7 +160,10 @@ class TestEpicRoutes:
|
||||||
|
|
||||||
def test_create_epic_board_not_found(self, client, db_session, auth_headers):
|
def test_create_epic_board_not_found(self, client, db_session, auth_headers):
|
||||||
"""Test creating epic for non-existent board"""
|
"""Test creating epic for non-existent board"""
|
||||||
epic_data = {"name": "Test Epic"}
|
epic_data = {
|
||||||
|
"name": "Test Epic",
|
||||||
|
"content": [{"type": "heading", "children": [{"text": "Updated Content"}]}],
|
||||||
|
}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/boards/99999/epics",
|
"/api/boards/99999/epics",
|
||||||
|
|
@ -416,7 +426,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"""
|
||||||
|
|
|
||||||
850
docs/figma-design-prompt.md
Normal file
850
docs/figma-design-prompt.md
Normal file
|
|
@ -0,0 +1,850 @@
|
||||||
|
# Taskboard — Figma AI Designer Prompt
|
||||||
|
|
||||||
|
> Use this document as a design brief for generating UI flows, wireframes, and high-fidelity mockups in Figma with AI assistance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Application Overview
|
||||||
|
|
||||||
|
**Taskboard** is a Trello-like Kanban project management web application. Users create Boards containing Lists (columns) and Cards (tasks). Cards support labels, checklists, comments, file attachments, due dates, card-to-card linking, and epic grouping. Boards also have **Epics** (large features spanning multiple cards) and **Wikis** (rich-text documentation pages).
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Frontend**: React 18 + TypeScript, Tailwind CSS (dark theme), React Router, @dnd-kit (drag-and-drop), Slate.js (rich text editor)
|
||||||
|
- **Backend**: Flask + SQLAlchemy + PostgreSQL
|
||||||
|
- **Auth**: JWT-based authentication
|
||||||
|
|
||||||
|
### Design System Basics
|
||||||
|
- **Theme**: Dark mode by default
|
||||||
|
- **Background**: `gray-900` (#111827) for page backgrounds, `gray-800` (#1f2937) for card/panel surfaces, `gray-700` (#374151) for inputs and hover states
|
||||||
|
- **Accent color**: `blue-600` (#2563eb) primary, `blue-500` hover, `blue-400` for text links
|
||||||
|
- **Text**: `white` for headings, `gray-300` for body text, `gray-400` for secondary/muted text, `gray-500` for placeholders
|
||||||
|
- **Borders**: `gray-700` for subtle dividers
|
||||||
|
- **Destructive**: `red-500`/`red-600` for delete/danger actions
|
||||||
|
- **Success**: `green-500`/`green-600`
|
||||||
|
- **Border radius**: `rounded-lg` (8px) for cards and inputs, `rounded-md` (6px) for buttons, `rounded-full` for badges/chips
|
||||||
|
- **Spacing**: Tailwind's 4px base unit (p-4 = 16px, gap-6 = 24px)
|
||||||
|
- **Typography**: System font stack; headings bold (font-bold), body medium (font-medium)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Navigation & Information Architecture
|
||||||
|
|
||||||
|
### Global Navigation Bar (Navbar)
|
||||||
|
- **Logo**: "Taskboard" wordmark + icon (left side), links to `/boards`
|
||||||
|
- **Nav links** (desktop): "Home" → `/home`, "Boards" → `/boards` (only when logged in)
|
||||||
|
- **Auth section** (right side):
|
||||||
|
- Logged out: "Login" link + "Register" button (blue-600 filled)
|
||||||
|
- Logged in: Username display + "Logout" button
|
||||||
|
- **Mobile**: Hamburger menu icon that expands to show same links vertically
|
||||||
|
- **Height**: 64px (h-16), bg-gray-800 with bottom border-gray-700
|
||||||
|
|
||||||
|
### Board-Level Sidebar (BoardSidebar)
|
||||||
|
- Fixed position on the **right edge** of the viewport, vertically centered
|
||||||
|
- Contains links for the current board context:
|
||||||
|
- 📋 **Epics** → `/boards/:id/epics`
|
||||||
|
- 📚 **Wikis** → `/boards/:id/wikis`
|
||||||
|
- 📜 **History** → `/boards/:id/history`
|
||||||
|
- Active item has blue-600 background; inactive has gray-800 with hover-gray-700
|
||||||
|
- Pill-shaped tabs with rounded-l-lg (left rounded only, flush to right edge)
|
||||||
|
|
||||||
|
### Full Route Map
|
||||||
|
|
||||||
|
| Route | Page | Auth Required |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `/home` | Landing/Marketing page | No |
|
||||||
|
| `/login` | Login page | No |
|
||||||
|
| `/register` | Registration page | No |
|
||||||
|
| `/boards` | Board listing | Yes |
|
||||||
|
| `/boards/new` | Create new board | Yes |
|
||||||
|
| `/boards/:id` | Board detail (Kanban board view) | Yes |
|
||||||
|
| `/boards/:id/edit` | Edit board settings | Yes |
|
||||||
|
| `/boards/:id/epics` | Epics listing for board | Yes |
|
||||||
|
| `/boards/:id/epics/new` | Create new epic | Yes |
|
||||||
|
| `/boards/:id/epics/:epicId` | Epic detail page | Yes |
|
||||||
|
| `/boards/:id/epics/:epicId/edit` | Edit epic | Yes |
|
||||||
|
| `/boards/:id/wikis` | Wikis listing for board | Yes |
|
||||||
|
| `/boards/:id/wikis/new` | Create new wiki | Yes |
|
||||||
|
| `/boards/:id/wikis/:wikiId` | Wiki detail page | Yes |
|
||||||
|
| `/boards/:id/wikis/:wikiId/edit` | Edit wiki | Yes |
|
||||||
|
| `/boards/:id/cards/:cardId` | Card detail page | Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Models & Their Fields
|
||||||
|
|
||||||
|
### User
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `email` | string (120) | Shown in auth forms |
|
||||||
|
| `username` | string (80) | Displayed in navbar, comments |
|
||||||
|
| `password_hash` | string | Never displayed |
|
||||||
|
| `first_name` | string (50) | Optional display |
|
||||||
|
| `last_name` | string (50) | Optional display |
|
||||||
|
| `is_active` | boolean | Account status |
|
||||||
|
| `is_admin` | boolean | Admin badge |
|
||||||
|
| `created_at` | datetime | Account creation date |
|
||||||
|
| `updated_at` | datetime | Last update |
|
||||||
|
|
||||||
|
### Board
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Board title — prominent heading |
|
||||||
|
| `description` | text | Subtitle/brief shown below title |
|
||||||
|
| `closed` | boolean | Archived indicator (badge or dimmed card) |
|
||||||
|
| `url` | string (500) | External URL reference |
|
||||||
|
| `short_link` | string (10) | Shareable short link |
|
||||||
|
| `short_url` | string (500) | Full short URL |
|
||||||
|
| `user_id` | FK → User | Board owner |
|
||||||
|
| `prefs` | JSONB | Board preferences (background, etc.) |
|
||||||
|
| `label_names` | JSONB | Label color→name mapping |
|
||||||
|
| `limits` | JSONB | Card/list limits |
|
||||||
|
| `date_last_activity` | datetime | "Last active" display |
|
||||||
|
| `date_last_view` | datetime | "Last viewed" display |
|
||||||
|
| `created_at` | datetime | Creation date |
|
||||||
|
| `updated_at` | datetime | Last update |
|
||||||
|
|
||||||
|
### List (Column)
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Column header title |
|
||||||
|
| `closed` | boolean | Archived indicator |
|
||||||
|
| `pos` | float | Horizontal sort order |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
| `created_at` | datetime | — |
|
||||||
|
| `updated_at` | datetime | — |
|
||||||
|
|
||||||
|
### Card (Task)
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Card title — main heading on detail page |
|
||||||
|
| `description` | text | Multi-line description; editable inline |
|
||||||
|
| `closed` | boolean | Archived indicator |
|
||||||
|
| `due` | datetime | Due date — shown as badge on card, date picker in editor |
|
||||||
|
| `due_complete` | boolean | Checkbox to mark due as done |
|
||||||
|
| `pos` | float | Sort position within list |
|
||||||
|
| `id_short` | integer | Short ID for display |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
| `list_id` | FK → List | Current list (shown as "In list [name]") |
|
||||||
|
| `epic_id` | FK → Epic | Assigned epic (nullable) |
|
||||||
|
| `badges` | JSONB | Stats (checklist count, comment count, attachment count) |
|
||||||
|
| `cover` | JSONB | Cover image settings |
|
||||||
|
| `desc_data` | JSONB | Rich description data |
|
||||||
|
| `date_last_activity` | datetime | — |
|
||||||
|
| `created_at` | datetime | "Created [date]" display |
|
||||||
|
| `updated_at` | datetime | — |
|
||||||
|
|
||||||
|
### Label
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (100) | Label text (shown on hover) |
|
||||||
|
| `color` | string (50) | Color name (green, red, blue, etc.) — rendered as colored chip/badge |
|
||||||
|
| `uses` | integer | Usage count |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Checklist heading |
|
||||||
|
| `pos` | float | Sort order among checklists |
|
||||||
|
| `card_id` | FK → Card | Parent card |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
|
||||||
|
### CheckItem
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (500) | Item text with checkbox |
|
||||||
|
| `pos` | float | Sort order |
|
||||||
|
| `state` | "complete" / "incomplete" | Checkbox state |
|
||||||
|
| `due` | datetime | Optional due date |
|
||||||
|
| `checklist_id` | FK → Checklist | Parent checklist |
|
||||||
|
| `user_id` | FK → User | Assigned user |
|
||||||
|
|
||||||
|
### Comment
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `text` | text | Comment body text |
|
||||||
|
| `card_id` | FK → Card | Parent card |
|
||||||
|
| `user_id` | FK → User | Author (display username + timestamp) |
|
||||||
|
| `created_at` | datetime | "Posted [relative time]" |
|
||||||
|
| `updated_at` | datetime | "Edited" indicator |
|
||||||
|
|
||||||
|
### FileAttachment
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `uuid` | string (36) | Public identifier |
|
||||||
|
| `filename` | string (255) | Storage filename |
|
||||||
|
| `original_name` | string (255) | Display name |
|
||||||
|
| `file_type` | string (50) | "image", "pdf", "document" — determines icon/preview |
|
||||||
|
| `mime_type` | string (100) | MIME type |
|
||||||
|
| `file_size` | integer | Display as "KB", "MB" |
|
||||||
|
| `attachable_type` | string (50) | "Card", "Comment", "Epic" — polymorphic parent |
|
||||||
|
| `attachable_id` | integer | ID of parent entity |
|
||||||
|
| `uploaded_by` | FK → User | Uploader |
|
||||||
|
| `thumbnail_url` | string | Thumbnail for images |
|
||||||
|
| `created_at` | datetime | Upload date |
|
||||||
|
|
||||||
|
### Epic
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Epic title |
|
||||||
|
| `description` | text | Brief description |
|
||||||
|
| `content` | JSONB | Rich text content (Slate.js JSON) |
|
||||||
|
| `color` | string (7) | Hex color for epic badge (e.g., "#FF5733") |
|
||||||
|
| `closed` | boolean | Completed/archived indicator |
|
||||||
|
| `pos` | float | Sort order |
|
||||||
|
| `depth_limit` | integer | Max nesting depth (default 5) |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
| `parent_epic_id` | FK → Epic (nullable) | Parent epic for hierarchy |
|
||||||
|
| `completed_list_id` | FK → List (nullable) | Which list counts as "done" |
|
||||||
|
| `metrics` | JSONB | `{ card_count: 10, completed_cards_count: 7 }` — progress bar |
|
||||||
|
| `date_last_activity` | datetime | — |
|
||||||
|
| `created_at` | datetime | — |
|
||||||
|
| `updated_at` | datetime | — |
|
||||||
|
|
||||||
|
### Wiki
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Wiki page title |
|
||||||
|
| `slug` | string (255) | URL-friendly identifier |
|
||||||
|
| `content` | JSONB | Rich text content (Slate.js JSON) |
|
||||||
|
| `summary` | text | Brief description/abstract |
|
||||||
|
| `category` | string (100) | Category grouping label |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
| `created_by` | FK → User | Author |
|
||||||
|
| `updated_by` | FK → User | Last editor |
|
||||||
|
| `tags` | JSONB | Array of tag strings: `["security", "api"]` |
|
||||||
|
| `created_at` | datetime | — |
|
||||||
|
| `updated_at` | datetime | — |
|
||||||
|
|
||||||
|
### CardLink (Card-to-Card Relationship)
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `parent_card_id` | FK → Card | Parent card |
|
||||||
|
| `child_card_id` | FK → Card | Child card |
|
||||||
|
| `created_by` | FK → User | Who linked them |
|
||||||
|
| `created_at` | datetime | When linked |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Page-by-Page UI Specifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 1: Landing / Home Page (`/home`)
|
||||||
|
|
||||||
|
**Purpose**: Marketing landing page introducing Taskboard. Visible to all visitors.
|
||||||
|
|
||||||
|
**Layout**: Full-width centered layout
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- **Hero Section**
|
||||||
|
- Large heading: "Taskboard" with tagline (e.g., "Organize your work, your way")
|
||||||
|
- Two CTA buttons: "Get Started" → `/register` (blue-600 filled), "Login" → `/login` (outline/ghost)
|
||||||
|
- Optional illustration or screenshot mockup
|
||||||
|
- **Features Section** (optional)
|
||||||
|
- 3-4 feature cards in a grid: Kanban boards, Epics, Wikis, Card linking
|
||||||
|
- **Footer** with basic links
|
||||||
|
|
||||||
|
**Interactions**: Click CTAs to navigate to auth pages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 2: Login Page (`/login`)
|
||||||
|
|
||||||
|
**Purpose**: User authentication
|
||||||
|
|
||||||
|
**Layout**: Narrow centered card on dark background
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- "Taskboard" logo/wordmark at top
|
||||||
|
- Heading: "Sign in to your account"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Email** — text input with label, placeholder "you@example.com"
|
||||||
|
- **Password** — password input with label, placeholder "••••••••"
|
||||||
|
- **Submit button**: "Sign In" (full width, blue-600)
|
||||||
|
- **Link**: "Don't have an account? Register" → `/register`
|
||||||
|
- Error message area (red text, shown on failed login)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 3: Register Page (`/register`)
|
||||||
|
|
||||||
|
**Purpose**: New user registration
|
||||||
|
|
||||||
|
**Layout**: Narrow centered card on dark background
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- "Taskboard" logo/wordmark at top
|
||||||
|
- Heading: "Create your account"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Username** — text input, placeholder "Choose a username"
|
||||||
|
- **Email** — text input, placeholder "you@example.com"
|
||||||
|
- **Password** — password input, placeholder "Create a password"
|
||||||
|
- **Confirm Password** — password input, placeholder "Confirm your password"
|
||||||
|
- **Submit button**: "Create Account" (full width, blue-600)
|
||||||
|
- **Link**: "Already have an account? Sign in" → `/login`
|
||||||
|
- Validation error messages per field
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 4: Boards List (`/boards`)
|
||||||
|
|
||||||
|
**Purpose**: View all boards the user owns/participates in
|
||||||
|
|
||||||
|
**Layout**: Wide page layout with padding
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- **Page header**:
|
||||||
|
- Heading: "My Boards"
|
||||||
|
- Button: "+ New Board" (blue-600) → navigates to `/boards/new`
|
||||||
|
- **Board grid** (responsive: 1 col mobile, 2 col tablet, 3-4 col desktop):
|
||||||
|
- Each **Board card** shows:
|
||||||
|
- Board name (bold, white text)
|
||||||
|
- Description snippet (gray-400, truncated)
|
||||||
|
- Metadata: "Last active: [relative date]" or "Created [date]"
|
||||||
|
- Card count indicator (optional badge)
|
||||||
|
- Closed/archived boards shown dimmed or with "Archived" badge
|
||||||
|
- Click on board card → navigates to `/boards/:id`
|
||||||
|
- **Empty state**: "No boards yet. Create your first board to get started!" with CTA button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 5: Create Board (`/boards/new`)
|
||||||
|
|
||||||
|
**Purpose**: Create a new Kanban board
|
||||||
|
|
||||||
|
**Layout**: Narrow centered form
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Boards" → `/boards`
|
||||||
|
- Heading: "Create New Board"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Board Name** — text input (required), placeholder "Enter board name"
|
||||||
|
- **Description** — textarea, placeholder "Describe your board (optional)"
|
||||||
|
- **Buttons**: "Create Board" (blue-600), "Cancel" (gray-600, navigates back)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 6: Edit Board (`/boards/:id/edit`)
|
||||||
|
|
||||||
|
**Purpose**: Edit board settings
|
||||||
|
|
||||||
|
**Layout**: Narrow centered form
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Board" → `/boards/:id`
|
||||||
|
- Heading: "Edit Board"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Board Name** — text input (pre-filled)
|
||||||
|
- **Description** — textarea (pre-filled)
|
||||||
|
- **Closed** — toggle/checkbox "Archive this board"
|
||||||
|
- **Buttons**: "Save Changes" (blue-600), "Cancel" (gray-600)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 7: Board Detail — Kanban View (`/boards/:id`) ★ PRIMARY PAGE
|
||||||
|
|
||||||
|
**Purpose**: The main Kanban board interface. This is the core of the application.
|
||||||
|
|
||||||
|
**Layout**: Full-width, horizontally scrollable. BoardSidebar on right edge.
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- **Top bar**:
|
||||||
|
- Breadcrumb: "← Back to Boards" → `/boards`
|
||||||
|
- Board name (h1, large, bold)
|
||||||
|
- Board description (gray-400, below name)
|
||||||
|
- Action buttons (right aligned):
|
||||||
|
- "Edit Board" (gray-700 button) → `/boards/:id/edit`
|
||||||
|
- "+ Add List" (blue-600 button)
|
||||||
|
- **Kanban board area** (horizontal flex container, scrollable):
|
||||||
|
- **List/Column** (each ~300px wide, bg-gray-800, rounded-lg):
|
||||||
|
- **Column header**: List name (bold), with dropdown menu (⋮) for: Rename list, Delete list
|
||||||
|
- **Card list** (vertical, scrollable):
|
||||||
|
- Each **KanbanCard** shows:
|
||||||
|
- Card name (white, medium weight)
|
||||||
|
- Label chips (colored dots or small pills below name)
|
||||||
|
- Badges row: checklist icon + count, comment icon + count, attachment icon + count
|
||||||
|
- Due date badge (if set): date text, red if overdue, green if complete
|
||||||
|
- Epic badge (if assigned): colored pill with epic name
|
||||||
|
- Parent card indicator: "Linked from: [card name]" (gray-400 text)
|
||||||
|
- **"Add card" button** at bottom of list: "+ Add a card" (text button, gray-400)
|
||||||
|
- **"Add another list" card**: Dashed border placeholder at end of columns
|
||||||
|
- **Drag & Drop**: Cards can be dragged between lists and reordered within lists. Lists can be reordered horizontally. Show ghost/overlay of dragged item.
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
- Click card → opens Card Preview Modal OR navigates to Card Detail
|
||||||
|
- Click "+ Add a card" → opens Create Card Modal
|
||||||
|
- Click "+ Add List" → opens Create List Modal
|
||||||
|
- Drag card/column to reorder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 8: Card Detail Page (`/boards/:id/cards/:cardId`) ★ KEY PAGE
|
||||||
|
|
||||||
|
**Purpose**: Full detailed view and editing of a single card
|
||||||
|
|
||||||
|
**Layout**: Narrow centered layout (max-width ~900px). Two-column grid: main content (2/3) + sidebar (1/3).
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
|
||||||
|
**Top Section**:
|
||||||
|
- Breadcrumb: "← Back to Board" → `/boards/:id`
|
||||||
|
- Card name (h1, 3xl bold, inline-editable — click edit icon to rename)
|
||||||
|
- Subtitle: "In list [list name] • Created [date]"
|
||||||
|
- **Action dropdown** (⋮ icon, top-right): Edit Name, Delete Card, Create Linked Card, Link Existing Card
|
||||||
|
|
||||||
|
**Main Content (left 2/3)**:
|
||||||
|
|
||||||
|
1. **Description Section** (bg-gray-800 card)
|
||||||
|
- Heading "Description" + "Edit" button
|
||||||
|
- Display mode: rendered text or "No description added yet." placeholder
|
||||||
|
- Edit mode: textarea with Save/Cancel buttons
|
||||||
|
|
||||||
|
2. **Labels Section**
|
||||||
|
- Colored label chips attached to this card
|
||||||
|
- Button to add/remove labels (opens label picker dropdown)
|
||||||
|
- Label picker: list of board labels as colored rows, click to toggle
|
||||||
|
|
||||||
|
3. **Epic Section**
|
||||||
|
- If assigned: colored epic badge with name, click to navigate to epic
|
||||||
|
- Button to assign/change epic (opens epic picker dropdown)
|
||||||
|
|
||||||
|
4. **Linked Cards Section**
|
||||||
|
- List of linked parent/child cards with name and link icon
|
||||||
|
- Each link has "Unlink" button (×)
|
||||||
|
- Empty state: "No linked cards"
|
||||||
|
|
||||||
|
5. **Checklists Section**
|
||||||
|
- Multiple checklists, each with:
|
||||||
|
- Checklist name heading + delete button (trash icon)
|
||||||
|
- Progress bar (percentage of completed items)
|
||||||
|
- List of CheckItems, each with:
|
||||||
|
- Checkbox (complete/incomplete)
|
||||||
|
- Item name (strikethrough when complete)
|
||||||
|
- Due date (if set)
|
||||||
|
- Action menu: Edit, Convert to Card, Delete
|
||||||
|
- "Add item" input at bottom
|
||||||
|
- "Add Checklist" button
|
||||||
|
|
||||||
|
6. **Attachments Section**
|
||||||
|
- File upload area (drag & drop or click to browse)
|
||||||
|
- List of uploaded files:
|
||||||
|
- Image preview (thumbnail) for images
|
||||||
|
- File icon + name for documents/PDFs
|
||||||
|
- File name, size, upload date
|
||||||
|
- Actions: View, Download, Delete
|
||||||
|
|
||||||
|
7. **Comments Section**
|
||||||
|
- Comment input: textarea + "Save" button
|
||||||
|
- List of comments (newest first):
|
||||||
|
- Author username + avatar placeholder
|
||||||
|
- Comment text
|
||||||
|
- Timestamp (relative: "2h ago")
|
||||||
|
- Actions: Edit (pencil icon), Delete (trash icon)
|
||||||
|
- Edit mode: inline textarea with Save/Cancel
|
||||||
|
|
||||||
|
**Sidebar (right 1/3)** — CardSidebar component:
|
||||||
|
- **Due Date**: Date display, "Mark complete" checkbox, date picker to change
|
||||||
|
- **List**: Current list name, dropdown to move card to another list
|
||||||
|
- **Epic**: Current epic or "None", link to epic detail
|
||||||
|
- **Created Date**: Full datetime
|
||||||
|
- **Last Activity**: Relative datetime
|
||||||
|
- **Card ID**: Short ID display
|
||||||
|
|
||||||
|
**Modals accessible from this page**:
|
||||||
|
- Delete Card Modal
|
||||||
|
- Create Linked Card Modal (name + description fields)
|
||||||
|
- Link Existing Card Modal (search/select from board cards)
|
||||||
|
- Unlink Card Modal (confirmation)
|
||||||
|
- Create Checklist Modal
|
||||||
|
- Delete Checklist Modal
|
||||||
|
- Edit Check Item Modal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 9: Board Epics (`/boards/:id/epics`)
|
||||||
|
|
||||||
|
**Purpose**: View and manage all epics for a board
|
||||||
|
|
||||||
|
**Layout**: Wide page layout with BoardSidebar
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Board" → `/boards/:id`
|
||||||
|
- Heading: "Epics"
|
||||||
|
- Button: "+ New Epic" (blue-600) → `/boards/:id/epics/new`
|
||||||
|
- **Epics list/table**:
|
||||||
|
- Each epic row/card shows:
|
||||||
|
- **Color dot** (epic color)
|
||||||
|
- **Epic name** (bold, links to detail page)
|
||||||
|
- **Description** snippet (truncated)
|
||||||
|
- **Progress bar**: `completed_cards_count / card_count` with percentage
|
||||||
|
- **Status**: Open/Closed badge
|
||||||
|
- **Last activity**: Relative date
|
||||||
|
- **Actions**: Edit (pencil), Delete (trash)
|
||||||
|
- Nested epics shown indented under parent
|
||||||
|
- **Empty state**: "No epics yet. Create an epic to group related cards."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 10: Create Epic (`/boards/:id/epics/new`)
|
||||||
|
|
||||||
|
**Purpose**: Create a new epic
|
||||||
|
|
||||||
|
**Layout**: Narrow centered form
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Epics" → `/boards/:id/epics`
|
||||||
|
- Heading: "Create New Epic"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Name** — text input (required), placeholder "Epic name"
|
||||||
|
- **Description** — textarea, placeholder "Brief description"
|
||||||
|
- **Color** — color picker or preset color swatches (hex input)
|
||||||
|
- **Rich Text Content** — Slate.js rich text editor with toolbar (bold, italic, lists, headings, links, images)
|
||||||
|
- **Parent Epic** — dropdown select (nullable, "None" option)
|
||||||
|
- **Completed List** — dropdown select (which list means "done")
|
||||||
|
- **Buttons**: "Create Epic" (blue-600), "Cancel" (gray-600)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 11: Edit Epic (`/boards/:id/epics/:epicId/edit`)
|
||||||
|
|
||||||
|
**Purpose**: Edit an existing epic
|
||||||
|
|
||||||
|
**Layout**: Same as Create Epic, pre-filled with existing data
|
||||||
|
|
||||||
|
**Additional fields**:
|
||||||
|
- **Closed** — toggle "Mark epic as completed/closed"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 12: Epic Detail (`/boards/:id/epics/:epicId`)
|
||||||
|
|
||||||
|
**Purpose**: View full epic details with progress and linked cards
|
||||||
|
|
||||||
|
**Layout**: Narrow centered layout
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Epics"
|
||||||
|
- **Header**:
|
||||||
|
- Epic name (h1) with color dot
|
||||||
|
- Description (gray-300)
|
||||||
|
- Status badge: Open/Closed
|
||||||
|
- "Edit" button → edit page
|
||||||
|
- **Progress Section**:
|
||||||
|
- Progress bar showing `completed_cards_count / card_count`
|
||||||
|
- Percentage text
|
||||||
|
- **Rich Text Content**: Rendered Slate.js content (formatted text, images, links)
|
||||||
|
- **Linked Cards**:
|
||||||
|
- List of cards assigned to this epic
|
||||||
|
- Each card shows: name, current list, labels, due date
|
||||||
|
- Click card → navigates to card detail
|
||||||
|
- **Child Epics** (if any):
|
||||||
|
- Nested list of sub-epics with same info
|
||||||
|
- **Attachments** (if any):
|
||||||
|
- File list with preview/download
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 13: Board Wikis (`/boards/:id/wikis`)
|
||||||
|
|
||||||
|
**Purpose**: View and manage all wiki pages for a board
|
||||||
|
|
||||||
|
**Layout**: Wide page layout with BoardSidebar
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Board" → `/boards/:id`
|
||||||
|
- Heading: "Wikis"
|
||||||
|
- Button: "+ New Wiki" (blue-600) → `/boards/:id/wikis/new`
|
||||||
|
- **Wiki list/grid**:
|
||||||
|
- Each wiki card shows:
|
||||||
|
- **Wiki name** (bold, links to detail)
|
||||||
|
- **Summary** (truncated, gray-400)
|
||||||
|
- **Category** badge (if set)
|
||||||
|
- **Tags** as small pills/chips
|
||||||
|
- **Author**: Created by [username]
|
||||||
|
- **Last updated**: Relative date
|
||||||
|
- **Actions**: Edit (pencil), Delete (trash)
|
||||||
|
- **Empty state**: "No wiki pages yet. Create a wiki to document your project."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 14: Create Wiki (`/boards/:id/wikis/new`)
|
||||||
|
|
||||||
|
**Purpose**: Create a new wiki page
|
||||||
|
|
||||||
|
**Layout**: Wide page layout (to accommodate rich text editor)
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Wikis" → `/boards/:id/wikis`
|
||||||
|
- Heading: "Create New Wiki"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Name** — text input (required), placeholder "Wiki page title"
|
||||||
|
- **Slug** — text input, auto-generated from name, editable, placeholder "url-friendly-slug"
|
||||||
|
- **Summary** — textarea, placeholder "Brief description"
|
||||||
|
- **Category** — text input, placeholder "Category (optional)"
|
||||||
|
- **Tags** — tag input (add/remove tags, shown as chips)
|
||||||
|
- **Content** — Slate.js rich text editor (full width):
|
||||||
|
- Toolbar: Bold, Italic, Underline, Strikethrough, Headings (H1-H3), Bulleted List, Numbered List, Link, Image, Code Block, Quote
|
||||||
|
- Large editable area
|
||||||
|
- **Buttons**: "Create Wiki" (blue-600), "Cancel" (gray-600)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 15: Edit Wiki (`/boards/:id/wikis/:wikiId/edit`)
|
||||||
|
|
||||||
|
**Purpose**: Edit an existing wiki page
|
||||||
|
|
||||||
|
**Layout**: Same as Create Wiki, pre-filled with existing data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 16: Wiki Detail (`/boards/:id/wikis/:wikiId`)
|
||||||
|
|
||||||
|
**Purpose**: View a wiki page's content
|
||||||
|
|
||||||
|
**Layout**: Narrow centered layout
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Wikis"
|
||||||
|
- **Header**:
|
||||||
|
- Wiki name (h1)
|
||||||
|
- Category badge (if set)
|
||||||
|
- Tags as colored chips
|
||||||
|
- Summary text (gray-300, italic)
|
||||||
|
- Metadata: "Created by [author] on [date] • Last updated [date] by [editor]"
|
||||||
|
- "Edit" button → edit page
|
||||||
|
- **Content**: Rendered Slate.js rich text (headings, paragraphs, lists, images, links, code blocks, quotes)
|
||||||
|
- **Linked Entities** (if any):
|
||||||
|
- "Linked Cards" section: list of linked cards with name and link
|
||||||
|
- "Linked Epics" section: list of linked epics with name and link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Modal Catalog
|
||||||
|
|
||||||
|
### Create List Modal
|
||||||
|
- **Triggered**: "+ Add List" button on board detail
|
||||||
|
- **Fields**: List Name (text input)
|
||||||
|
- **Actions**: "Create List" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Edit List Modal
|
||||||
|
- **Triggered**: Rename option from list dropdown menu
|
||||||
|
- **Fields**: List Name (text input, pre-filled)
|
||||||
|
- **Actions**: "Save" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Delete List Modal
|
||||||
|
- **Triggered**: Delete option from list dropdown menu
|
||||||
|
- **Content**: "Are you sure you want to delete '[list name]'? All cards in this list will be permanently deleted."
|
||||||
|
- **Actions**: "Delete" (red-600), "Cancel"
|
||||||
|
|
||||||
|
### Create Card Modal
|
||||||
|
- **Triggered**: "+ Add a card" button at bottom of a list
|
||||||
|
- **Fields**: Card Name (text input, required), Description (textarea, optional)
|
||||||
|
- **Actions**: "Create Card" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Edit Card Modal
|
||||||
|
- **Triggered**: Edit action on card
|
||||||
|
- **Fields**: Card Name (text input), Description (textarea)
|
||||||
|
- **Actions**: "Save" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Delete Card Modal
|
||||||
|
- **Triggered**: Delete action from card dropdown
|
||||||
|
- **Content**: "Are you sure you want to delete '[card name]'? This action cannot be undone."
|
||||||
|
- **Actions**: "Delete" (red-600), "Cancel"
|
||||||
|
|
||||||
|
### Card Preview Modal
|
||||||
|
- **Triggered**: Clicking a card on the Kanban board
|
||||||
|
- **Content**: Compact card view with name, description, labels, badges
|
||||||
|
- **Actions**: "View Full Details" (navigates to card detail), "Close"
|
||||||
|
|
||||||
|
### Create Linked Card Modal
|
||||||
|
- **Triggered**: "Create Linked Card" from card action dropdown
|
||||||
|
- **Purpose**: Create a new card and automatically link it as a child
|
||||||
|
- **Fields**: Card Name (text input), Description (textarea)
|
||||||
|
- **Actions**: "Create & Link" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Link Existing Card Modal
|
||||||
|
- **Triggered**: "Link Existing Card" from card action dropdown
|
||||||
|
- **Purpose**: Search and select an existing card on the board to link
|
||||||
|
- **Fields**: Search input, list of matching cards with radio buttons
|
||||||
|
- **Actions**: "Link Card" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Unlink Card Modal
|
||||||
|
- **Triggered**: "Unlink" button on a linked card
|
||||||
|
- **Content**: "Are you sure you want to unlink '[card name]' from '[parent card name]'?"
|
||||||
|
- **Actions**: "Unlink" (red-600), "Cancel"
|
||||||
|
|
||||||
|
### Create Checklist Modal
|
||||||
|
- **Triggered**: "Add Checklist" button in card checklists section
|
||||||
|
- **Fields**: Checklist Name (text input)
|
||||||
|
- **Actions**: "Add" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Delete Checklist Modal
|
||||||
|
- **Triggered**: Delete button on a checklist
|
||||||
|
- **Content**: "Are you sure you want to delete the checklist '[name]'? All items will be removed."
|
||||||
|
- **Actions**: "Delete" (red-600), "Cancel"
|
||||||
|
|
||||||
|
### Edit Check Item Modal
|
||||||
|
- **Triggered**: Edit action on a checklist item
|
||||||
|
- **Fields**: Item Name (text input)
|
||||||
|
- **Actions**: "Save" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Create Label Modal
|
||||||
|
- **Triggered**: "Create new label" option in label picker
|
||||||
|
- **Fields**: Label Name (text input), Color (color swatches picker)
|
||||||
|
- **Actions**: "Create" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Key User Flows
|
||||||
|
|
||||||
|
### Flow 1: Registration & First Board
|
||||||
|
1. User visits `/home` → clicks "Get Started"
|
||||||
|
2. Fills registration form on `/register` → submits
|
||||||
|
3. Auto-logged in → redirected to `/boards`
|
||||||
|
4. Empty state shown → clicks "+ New Board"
|
||||||
|
5. Enters board name + description → clicks "Create Board"
|
||||||
|
6. Redirected to new board `/boards/:id` → empty board with no lists
|
||||||
|
7. Clicks "+ Add List" → enters list name (e.g., "To Do", "In Progress", "Done")
|
||||||
|
8. Repeats for more lists
|
||||||
|
|
||||||
|
### Flow 2: Creating and Managing Cards
|
||||||
|
1. On board view, clicks "+ Add a card" in a list
|
||||||
|
2. Enters card name → creates card
|
||||||
|
3. Clicks card → card preview modal opens
|
||||||
|
4. Clicks "View Full Details" → navigates to Card Detail page
|
||||||
|
5. Adds description, assigns labels, sets due date
|
||||||
|
6. Adds checklist with items, checks off items
|
||||||
|
7. Adds comments
|
||||||
|
8. Uploads file attachments
|
||||||
|
9. Returns to board → sees updated card with badges
|
||||||
|
|
||||||
|
### Flow 3: Card Linking
|
||||||
|
1. On Card Detail page, clicks action dropdown (⋮)
|
||||||
|
2. Selects "Create Linked Card" → enters name/description → creates
|
||||||
|
3. OR selects "Link Existing Card" → searches board cards → selects one → links
|
||||||
|
4. Linked cards shown in "Linked Cards" section
|
||||||
|
5. Can unlink via × button → confirms in Unlink Card Modal
|
||||||
|
|
||||||
|
### Flow 4: Epic Management
|
||||||
|
1. From board sidebar, clicks "Epics"
|
||||||
|
2. On epics page, clicks "+ New Epic"
|
||||||
|
3. Fills in name, description, color, rich content, assigns completed list
|
||||||
|
4. On board view, opens a card → assigns the epic
|
||||||
|
5. Epic metrics auto-update (card count, completion progress)
|
||||||
|
|
||||||
|
### Flow 5: Wiki Documentation
|
||||||
|
1. From board sidebar, clicks "Wikis"
|
||||||
|
2. Clicks "+ New Wiki"
|
||||||
|
3. Enters title, summary, category, tags
|
||||||
|
4. Writes rich text content in editor
|
||||||
|
5. Saves → views rendered wiki page
|
||||||
|
6. Can link wiki to cards/epics
|
||||||
|
|
||||||
|
### Flow 6: Drag & Drop on Board
|
||||||
|
1. User hovers over a card → cursor changes to grab
|
||||||
|
2. Drags card to new position in same list OR to different list
|
||||||
|
3. Ghost overlay follows cursor
|
||||||
|
4. On drop, card moves to new position/list
|
||||||
|
5. Lists can also be reordered by dragging column headers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Component Inventory
|
||||||
|
|
||||||
|
### Reusable UI Components
|
||||||
|
| Component | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| **Navbar** | Top navigation with logo, links, auth controls |
|
||||||
|
| **BoardSidebar** | Fixed right-side nav for board sections |
|
||||||
|
| **KanbanColumn** | Vertical list container with header and card list |
|
||||||
|
| **SortableKanbanColumn** | Drag-enabled version of KanbanColumn |
|
||||||
|
| **KanbanCard** | Card summary shown in columns (name, badges, labels) |
|
||||||
|
| **BoardCard** | Board summary card for the boards listing page |
|
||||||
|
| **CardPreviewModal** | Quick card preview popup |
|
||||||
|
| **CardSidebar** | Right sidebar on card detail (due date, list, etc.) |
|
||||||
|
| **CardLabels** | Label chips display + picker |
|
||||||
|
| **LabelDropdown** | Dropdown for selecting/toggling labels |
|
||||||
|
| **CardEpics** | Epic assignment display + picker |
|
||||||
|
| **CardLinks** | Linked cards display with unlink action |
|
||||||
|
| **CardChecklists** | Checklists with checkable items, progress bars |
|
||||||
|
| **CardComments** | Comment thread with add/edit/delete |
|
||||||
|
| **CardAttachments** | File upload area + attachment list |
|
||||||
|
| **CardActionDropdown** | Context menu (⋮) for card actions |
|
||||||
|
| **RichTextEditor** | Slate.js rich text editor with formatting toolbar |
|
||||||
|
| **RichTextContent** | Read-only rich text renderer |
|
||||||
|
| **SecureImage** | Authenticated image component (MinIO-backed) |
|
||||||
|
| **WidePageLayout** | Full-width page wrapper with standard padding |
|
||||||
|
| **NarrowPageLayout** | Centered narrow content wrapper (~900px max) |
|
||||||
|
| **DeleteCardModal** | Confirmation dialog for card deletion |
|
||||||
|
| **DeleteListModal** | Confirmation dialog for list deletion |
|
||||||
|
| **CreateListModal** | Form modal for creating a new list |
|
||||||
|
| **EditListModal** | Form modal for editing a list name |
|
||||||
|
| **CreateCardModal** | Form modal for creating a new card |
|
||||||
|
| **EditCardModal** | Form modal for editing card name/description |
|
||||||
|
| **CreateLinkedCardModal** | Form modal for creating + linking a card |
|
||||||
|
| **LinkExistingCardModal** | Search/select modal for linking existing cards |
|
||||||
|
| **UnlinkCardModal** | Confirmation modal for unlinking cards |
|
||||||
|
| **CreateChecklistModal** | Form modal for adding a checklist |
|
||||||
|
| **DeleteChecklistModal** | Confirmation modal for deleting a checklist |
|
||||||
|
| **EditCheckItemModal** | Form modal for editing a checklist item |
|
||||||
|
| **CreateLabelModal** | Form modal for creating a new label |
|
||||||
|
| **ProtectedRoute** | Auth guard wrapper component |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. States to Design
|
||||||
|
|
||||||
|
For each page, please design these states:
|
||||||
|
|
||||||
|
1. **Loading state**: Skeleton/spinner while data loads
|
||||||
|
2. **Empty state**: Helpful message + CTA when no data exists
|
||||||
|
3. **Populated state**: Normal data display
|
||||||
|
4. **Error state**: Error message display (toast notification pattern)
|
||||||
|
5. **Mobile responsive**: Stack layouts vertically, hamburger nav, full-width cards
|
||||||
|
|
||||||
|
### Special States
|
||||||
|
- **Card dragging**: Ghost overlay of card at cursor position
|
||||||
|
- **Column dragging**: Ghost overlay of column at cursor position
|
||||||
|
- **Inline editing**: Input fields replacing display text (card name, description)
|
||||||
|
- **Modal overlay**: Dark backdrop with centered modal card
|
||||||
|
- **Toast notifications**: Top-right corner, auto-dismiss, types: success (green), error (red), info (blue)
|
||||||
|
- **Archived items**: Dimmed with "Archived" badge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Responsive Breakpoints
|
||||||
|
|
||||||
|
| Breakpoint | Width | Layout Changes |
|
||||||
|
|-----------|-------|----------------|
|
||||||
|
| Mobile | < 640px | Single column, hamburger nav, stacked cards, full-width modals |
|
||||||
|
| Tablet | 640-1024px | 2-column grids, collapsible sidebar |
|
||||||
|
| Desktop | > 1024px | Full kanban horizontal scroll, 3-column card detail, sidebar visible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Design Priorities
|
||||||
|
|
||||||
|
1. **Board Detail (Kanban View)** — This is the most-used page; prioritize its drag-and-drop UX
|
||||||
|
2. **Card Detail** — Second most important; dense information layout
|
||||||
|
3. **Card Detail Modals** — Many user workflows happen through modals
|
||||||
|
4. **Boards List** — Entry point after login
|
||||||
|
5. **Epic Detail & Wiki Detail** — Supporting features
|
||||||
|
6. **Auth Pages** — Simple but must look polished
|
||||||
|
7. **Forms (Create/Edit)** — Standard patterns, focus on rich text editor UX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of design brief. Use this document to generate complete UI flows and page designs for the Taskboard application.*
|
||||||
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">
|
||||||
|
|
|
||||||
|
|
@ -466,8 +466,11 @@ export const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
|
||||||
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
|
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
|
||||||
return 'align' in element;
|
return 'align' in element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SlateRenderElement = ({ attributes, children, element }: RenderElementProps) => {
|
export const SlateRenderElement = ({ attributes, children, element }: RenderElementProps) => {
|
||||||
switch (element.type) {
|
console.log('SlateRenderElement', element);
|
||||||
|
const elementType = element.type ? element.type.split(' ')[0] : '';
|
||||||
|
switch (elementType) {
|
||||||
case 'block-quote':
|
case 'block-quote':
|
||||||
return (
|
return (
|
||||||
<blockquote
|
<blockquote
|
||||||
|
|
@ -486,6 +489,15 @@ export const SlateRenderElement = ({ attributes, children, element }: RenderElem
|
||||||
<code>{children}</code>
|
<code>{children}</code>
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
|
case 'code-line':
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className="inline bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto my-4 font-mono text-sm border border-gray-700"
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
case 'bulleted-list':
|
case 'bulleted-list':
|
||||||
return (
|
return (
|
||||||
<ul className="list-disc pl-5 my-4 text-gray-200" {...attributes}>
|
<ul className="list-disc pl-5 my-4 text-gray-200" {...attributes}>
|
||||||
|
|
@ -512,7 +524,7 @@ export const SlateRenderElement = ({ attributes, children, element }: RenderElem
|
||||||
);
|
);
|
||||||
case 'list-item':
|
case 'list-item':
|
||||||
return (
|
return (
|
||||||
<li className="my-1 text-gray-200" {...attributes}>
|
<li className="my-1 mx-2 text-gray-200" {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
@ -523,11 +535,7 @@ export const SlateRenderElement = ({ attributes, children, element }: RenderElem
|
||||||
</ol>
|
</ol>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return <div {...attributes}>{children}</div>;
|
||||||
<div className="inline my-2 mr-2 leading-relaxed text-gray-200" {...attributes}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
|
@ -52,15 +52,33 @@ function useEpics(boardId: string) {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
return newEpic;
|
return newEpic;
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create epic';
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create epic';
|
||||||
setError(err instanceof Error ? err : new Error(errorMessage));
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
if (err.status === 400 && err.response?.data?.validation_error?.body_params) {
|
||||||
title: 'Error Creating Epic',
|
const validationErrors = err.response.data.validation_error.body_params;
|
||||||
message: errorMessage,
|
|
||||||
duration: 5000,
|
for (let index = 0; index < validationErrors.length; index++) {
|
||||||
});
|
const validationError = validationErrors[index];
|
||||||
|
|
||||||
|
const validationMessage = `${validationError.loc.join(', ')} ${validationError.msg}`;
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Creating Epic',
|
||||||
|
message: validationMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Creating Epic',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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