Merge pull request 'add epics and wikis features' (#7) from feature-epics-wikis into main

Reviewed-on: http://localhost:3000/david/flask_react_monorepo_template/pulls/7
This commit is contained in:
david 2026-04-12 13:14:19 +03:00
commit 65d0a32cb5
83 changed files with 7758 additions and 1781 deletions

View file

@ -136,7 +136,7 @@ format-frontend: ## Format frontend code only
@echo "Formatting frontend..." @echo "Formatting frontend..."
cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}" cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}"
migrate: ## Run database migrations backend-upgrade: ## Run database migrations
cd backend && . venv/bin/activate && flask db upgrade cd backend && . venv/bin/activate && flask db upgrade
shell: ## Open Flask shell shell: ## Open Flask shell

View file

@ -39,6 +39,18 @@ class Config:
"pool_pre_ping": True, # Verify connections before using "pool_pre_ping": True, # Verify connections before using
} }
# MinIO Configuration
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000")
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
MINIO_REGION = os.environ.get("MINIO_REGION", "garage")
MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "true").lower() == "true"
# MinIO Buckets
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
class DevelopmentConfig(Config): class DevelopmentConfig(Config):
"""Development configuration""" """Development configuration"""

View file

@ -0,0 +1,21 @@
from app.decorators.owned import (load_board_owned, load_card_owned,
load_check_item_owned, load_checklist_owned,
load_comment_owned, load_file_owned,
load_list_owned)
from .decorators import get_current_user_id, load_file_accessible_by_uuid
from .schema_validations import serialize_list_response, serialize_response
__all__ = [
"load_board_owned",
"load_card_owned",
"load_list_owned",
"load_checklist_owned",
"load_check_item_owned",
"load_comment_owned",
"load_file_owned",
"get_current_user_id",
"load_file_accessible_by_uuid",
"serialize_response",
"serialize_list_response",
]

View file

@ -3,8 +3,7 @@ from functools import wraps
from flask import abort, g from flask import abort, g
from flask_jwt_extended import get_jwt_identity from flask_jwt_extended import get_jwt_identity
from app.models import (Board, Card, CheckItem, Checklist, Comment, from app.models import Board, Card, Comment, FileAttachment
FileAttachment, List)
def get_current_user_id(): def get_current_user_id():
@ -15,176 +14,6 @@ def get_current_user_id():
return g.jwt_identity return g.jwt_identity
def load_board_owned(f):
"""
Loads a Board and ensures it belongs to the current user.
Injects 'board' into the route kwargs.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
board_id = kwargs.get("board_id")
# SECURE QUERY: Filter by ID *and* User ID in the DB
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
if not board:
abort(404)
kwargs["board"] = board
return f(*args, **kwargs)
return decorated_function
def load_card_owned(f):
"""
Loads a Card and ensures its Parent Board belongs to the current user.
Injects 'card' into the route kwargs.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
card_id = kwargs.get("card_id")
# Join Board to check ownership securely in one query
card = (
Card.query.join(Board)
.filter(Card.id == card_id, Board.user_id == user_id)
.first()
)
if not card:
abort(404)
kwargs["card"] = card
return f(*args, **kwargs)
return decorated_function
def load_list_owned(f):
"""Loads a List ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
list_id = kwargs.get("list_id")
lst = (
List.query.join(Board)
.filter(List.id == list_id, Board.user_id == user_id)
.first()
)
if not lst:
abort(404)
kwargs["lst"] = lst
return f(*args, **kwargs)
return decorated_function
def load_checklist_owned(f):
"""Loads a Checklist ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
checklist_id = kwargs.get("checklist_id")
checklist = (
Checklist.query.join(Card)
.join(Board)
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
.first()
)
if not checklist:
abort(404)
kwargs["checklist"] = checklist
return f(*args, **kwargs)
return decorated_function
def load_check_item_owned(f):
"""Loads a CheckItem ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
item_id = kwargs.get("item_id")
check_item = (
CheckItem.query.join(Checklist)
.join(Card)
.join(Board)
.filter(CheckItem.id == item_id, Board.user_id == user_id)
.first()
)
if not check_item:
abort(404)
kwargs["check_item"] = check_item
return f(*args, **kwargs)
return decorated_function
def load_comment_owned(f):
"""
Loads a Comment ensuring the Comment itself belongs to the user.
(Based on schema where Comment has user_id)
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
comment_id = kwargs.get("comment_id")
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
if not comment:
abort(404)
kwargs["comment"] = comment
return f(*args, **kwargs)
return decorated_function
def load_file_owned(f):
"""
Loads a FileAttachment ensuring it belongs to the user.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Filter by ID and user ID
attachment = FileAttachment.query.filter_by(
id=file_id, uploaded_by=user_id
).first()
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function
def load_file_accessible(f): def load_file_accessible(f):
""" """
Loads a FileAttachment ensuring the user can access it. Loads a FileAttachment ensuring the user can access it.

View file

@ -0,0 +1,177 @@
from functools import wraps
from flask import abort
from app.decorators.decorators import get_current_user_id
from app.models import (Board, Card, CheckItem, Checklist, Comment,
FileAttachment, List)
def load_board_owned(f):
"""
Loads a Board and ensures it belongs to the current user.
Injects 'board' into the route kwargs.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
board_id = kwargs.get("board_id")
# SECURE QUERY: Filter by ID *and* User ID in the DB
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
if not board:
abort(404)
kwargs["board"] = board
return f(*args, **kwargs)
return decorated_function
def load_card_owned(f):
"""
Loads a Card and ensures its Parent Board belongs to the current user.
Injects 'card' into the route kwargs.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
card_id = kwargs.get("card_id")
# Join Board to check ownership securely in one query
card = (
Card.query.join(Board)
.filter(Card.id == card_id, Board.user_id == user_id)
.first()
)
if not card:
abort(404)
kwargs["card"] = card
return f(*args, **kwargs)
return decorated_function
def load_list_owned(f):
"""Loads a List ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
list_id = kwargs.get("list_id")
lst = (
List.query.join(Board)
.filter(List.id == list_id, Board.user_id == user_id)
.first()
)
if not lst:
abort(404)
kwargs["lst"] = lst
return f(*args, **kwargs)
return decorated_function
def load_checklist_owned(f):
"""Loads a Checklist ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
checklist_id = kwargs.get("checklist_id")
checklist = (
Checklist.query.join(Card)
.join(Board)
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
.first()
)
if not checklist:
abort(404)
kwargs["checklist"] = checklist
return f(*args, **kwargs)
return decorated_function
def load_check_item_owned(f):
"""Loads a CheckItem ensuring Parent Board ownership."""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
item_id = kwargs.get("item_id")
check_item = (
CheckItem.query.join(Checklist)
.join(Card)
.join(Board)
.filter(CheckItem.id == item_id, Board.user_id == user_id)
.first()
)
if not check_item:
abort(404)
kwargs["check_item"] = check_item
return f(*args, **kwargs)
return decorated_function
def load_comment_owned(f):
"""
Loads a Comment ensuring the Comment itself belongs to the user.
(Based on schema where Comment has user_id)
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
comment_id = kwargs.get("comment_id")
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
if not comment:
abort(404)
kwargs["comment"] = comment
return f(*args, **kwargs)
return decorated_function
def load_file_owned(f):
"""
Loads a FileAttachment ensuring it belongs to the user.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Filter by ID and user ID
attachment = FileAttachment.query.filter_by(
id=file_id, uploaded_by=user_id
).first()
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function

View file

@ -0,0 +1,38 @@
from functools import wraps
from flask import jsonify
def serialize_response(model):
"""Decorator to serialize single entity response"""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
result, status = f(*args, **kwargs)
if status not in [200, 201]:
return result, status
return jsonify(model.model_validate(result).model_dump()), status
return wrapper
return decorator
def serialize_list_response(model):
"""Decorator to serialize list response"""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
results, status = f(*args, **kwargs)
if status not in [200, 201]:
return results, status
return (
jsonify([model.model_validate(item).model_dump() for item in results]),
status,
)
return wrapper
return decorator

View file

@ -4,18 +4,15 @@ from app.models.card_label import CardLabel
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
from app.models.epic import Epic
from app.models.file_attachment import FileAttachment from app.models.file_attachment import FileAttachment
from app.models.label import Label from app.models.label import Label
from app.models.list_model import List from app.models.list_model import List
from app.models.order import Order, OrderItem
from app.models.product import Product
from app.models.user import User from app.models.user import User
from app.models.wiki import Wiki, wiki_entity_links
__all__ = [ __all__ = [
"User", "User",
"Product",
"Order",
"OrderItem",
"Board", "Board",
"List", "List",
"Card", "Card",
@ -25,4 +22,7 @@ __all__ = [
"CheckItem", "CheckItem",
"Comment", "Comment",
"FileAttachment", "FileAttachment",
"Epic",
"Wiki",
"wiki_entity_links",
] ]

View file

@ -32,6 +32,9 @@ class Card(db.Model):
nullable=False, nullable=False,
index=True, index=True,
) )
epic_id = db.Column(
db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL"), index=True
)
# Timestamps # Timestamps
date_last_activity = db.Column(db.DateTime) date_last_activity = db.Column(db.DateTime)
@ -79,6 +82,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,
"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
else None, else None,
@ -91,3 +95,130 @@ class Card(db.Model):
def __repr__(self): def __repr__(self):
return f"<Card {self.name}>" return f"<Card {self.name}>"
# SQLAlchemy event listeners to update Epic metrics
def update_epic_metrics_on_card_change(mapper, connection, target):
"""Update epic card_count when card epic_id changes"""
if target.epic_id:
from sqlalchemy import select, update
from app.models import Epic
# Get total card count
card_count_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id
)
card_count = connection.execute(card_count_stmt).scalar()
# Get epic's completed_list_id
completed_list_id_stmt = select(Epic.completed_list_id).where(
Epic.id == target.epic_id
)
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
# Get completed card count (only if epic has completed_list_id)
completed_cards_count = 0
if completed_list_id:
completed_cards_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id, Card.list_id == completed_list_id
)
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
# Update epic metrics
connection.execute(
update(Epic)
.where(Epic.id == target.epic_id)
.values(
metrics={
"card_count": card_count,
"completed_cards_count": completed_cards_count,
}
)
)
def update_epic_metrics_on_card_insert(mapper, connection, target):
"""Update epic card_count when a card is added to an epic"""
if target.epic_id:
from sqlalchemy import select, update
from app.models import Epic
# Get total card count
card_count_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id
)
card_count = connection.execute(card_count_stmt).scalar()
# Get epic's completed_list_id
completed_list_id_stmt = select(Epic.completed_list_id).where(
Epic.id == target.epic_id
)
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
# Get completed card count (only if epic has completed_list_id)
completed_cards_count = 0
if completed_list_id:
completed_cards_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id, Card.list_id == completed_list_id
)
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
# Update epic metrics
connection.execute(
update(Epic)
.where(Epic.id == target.epic_id)
.values(
metrics={
"card_count": card_count,
"completed_cards_count": completed_cards_count,
}
)
)
def update_epic_metrics_on_card_delete(mapper, connection, target):
"""Update epic card_count when a card is removed from an epic"""
if target.epic_id:
from sqlalchemy import select, update
from app.models import Epic
# Get total card count
card_count_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id
)
card_count = connection.execute(card_count_stmt).scalar()
# Get epic's completed_list_id
completed_list_id_stmt = select(Epic.completed_list_id).where(
Epic.id == target.epic_id
)
completed_list_id = connection.execute(completed_list_id_stmt).scalar()
# Get completed card count (only if epic has completed_list_id)
completed_cards_count = 0
if completed_list_id:
completed_cards_stmt = select(db.func.count(Card.id)).where(
Card.epic_id == target.epic_id, Card.list_id == completed_list_id
)
completed_cards_count = connection.execute(completed_cards_stmt).scalar()
# Update epic metrics
connection.execute(
update(Epic)
.where(Epic.id == target.epic_id)
.values(
metrics={
"card_count": card_count,
"completed_cards_count": completed_cards_count,
}
)
)
# Register event listeners
db.event.listen(Card, "after_update", update_epic_metrics_on_card_change)
db.event.listen(Card, "after_insert", update_epic_metrics_on_card_insert)
db.event.listen(Card, "after_delete", update_epic_metrics_on_card_delete)

View file

@ -0,0 +1,87 @@
from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB
from app import db
class Epic(db.Model):
"""Epic model for tracking large features across multiple cards"""
__tablename__ = "epics"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
description = db.Column(db.Text)
content = db.Column(JSONB) # Rich text content (Slate.js JSON)
color = db.Column(db.String(7)) # Hex color for epic badge
closed = db.Column(db.Boolean, default=False, index=True)
pos = db.Column(db.Float) # Position for sorting in epic list
depth_limit = db.Column(db.Integer, default=5) # Max nesting depth
# Foreign keys
board_id = db.Column(
db.Integer,
db.ForeignKey("boards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
parent_epic_id = db.Column(
db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL")
)
completed_list_id = db.Column(
db.Integer,
db.ForeignKey("lists.id", ondelete="SET NULL"),
index=True,
)
# Timestamps
date_last_activity = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# JSON fields for metrics
metrics = db.Column(JSONB) # {"card_count": 10}
# Relationships
board = db.relationship("Board", backref="epics")
parent_epic = db.relationship("Epic", remote_side=[id], backref="child_epics")
cards = db.relationship("Card", backref="epic", cascade="all, delete-orphan")
attachments = db.relationship(
"FileAttachment",
foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Epic.id,
FileAttachment.attachable_type == 'Epic')""",
cascade="all, delete-orphan",
lazy="dynamic",
overlaps="attachments",
)
def to_dict(self):
"""Convert epic to dictionary"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"content": self.content,
"color": self.color,
"closed": self.closed,
"pos": self.pos,
"depth_limit": self.depth_limit,
"board_id": self.board_id,
"parent_epic_id": self.parent_epic_id,
"completed_list_id": self.completed_list_id,
"date_last_activity": self.date_last_activity.isoformat()
if self.date_last_activity
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,
"metrics": self.metrics or {"card_count": 0, "completed_cards_count": 0},
}
def __repr__(self):
return f"<Epic {self.name}>"

View file

@ -1,75 +0,0 @@
from datetime import UTC, datetime
from app import db
class Order(db.Model):
"""Order model"""
__tablename__ = "orders"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
status = db.Column(db.String(20), default="pending", index=True)
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
shipping_address = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationships
user = db.relationship("User", back_populates="orders")
items = db.relationship(
"OrderItem",
back_populates="order",
lazy="dynamic",
cascade="all, delete-orphan",
)
def to_dict(self):
"""Convert order to dictionary"""
return {
"id": self.id,
"user_id": self.user_id,
"status": self.status,
"total_amount": float(self.total_amount) if self.total_amount else None,
"shipping_address": self.shipping_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"items": [item.to_dict() for item in self.items],
}
def __repr__(self):
return f"<Order {self.id}>"
class OrderItem(db.Model):
"""Order Item model"""
__tablename__ = "order_items"
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False)
quantity = db.Column(db.Integer, nullable=False)
price = db.Column(db.Numeric(10, 2), nullable=False)
# Relationships
order = db.relationship("Order", back_populates="items")
product = db.relationship("Product", back_populates="order_items")
def to_dict(self):
"""Convert order item to dictionary"""
return {
"id": self.id,
"order_id": self.order_id,
"product_id": self.product_id,
"quantity": self.quantity,
"price": float(self.price) if self.price else None,
}
def __repr__(self):
return f"<OrderItem {self.id}>"

View file

@ -1,43 +0,0 @@
from datetime import UTC, datetime
from app import db
class Product(db.Model):
"""Product model"""
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
description = db.Column(db.Text)
price = db.Column(db.Numeric(10, 2), nullable=False)
stock = db.Column(db.Integer, default=0)
image_url = db.Column(db.String(500))
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationships
order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic")
def to_dict(self):
"""Convert product to dictionary"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"price": float(self.price) if self.price else None,
"stock": self.stock,
"image_url": self.image_url,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f"<Product {self.name}>"

View file

@ -26,7 +26,6 @@ class User(db.Model):
) )
# Relationships # Relationships
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
boards = db.relationship( boards = db.relationship(
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic" "Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
) )

111
backend/app/models/wiki.py Normal file
View file

@ -0,0 +1,111 @@
from datetime import UTC, datetime
from sqlalchemy import and_
from sqlalchemy.dialects.postgresql import JSONB
from app import db
from app.models.card import Card
from app.models.epic import Epic
wiki_entity_links = db.Table(
"wiki_entity_links",
db.Column(
"wiki_id",
db.Integer,
db.ForeignKey("wikis.id", ondelete="CASCADE"),
primary_key=True,
),
db.Column("entity_type", db.String(50), nullable=False), # 'Card', 'Epic'
db.Column("entity_id", db.Integer, nullable=False), # ID of the linked entity
db.Column("created_at", db.DateTime, default=lambda: datetime.now(UTC)),
db.Column("linked_by", db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")),
)
class Wiki(db.Model):
"""Wiki model for reusable rich text content within a board"""
__tablename__ = "wikis"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
slug = db.Column(db.String(255), index=True) # URL-friendly identifier
content = db.Column(JSONB, nullable=False) # Rich text content (Slate.js JSON)
summary = db.Column(db.Text) # Brief description/abstract
category = db.Column(db.String(100)) # Optional categorization
# Foreign keys
board_id = db.Column(
db.Integer,
db.ForeignKey("boards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by = db.Column(
db.Integer,
db.ForeignKey("users.id", ondelete="SET NULL"),
)
updated_by = db.Column(
db.Integer,
db.ForeignKey("users.id", ondelete="SET NULL"),
)
# Timestamps
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# JSON fields
tags = db.Column(JSONB) # List of tags for organization: ["security", "api"]
# Relationships
board = db.relationship("Board", backref="wikis")
creator = db.relationship(
"User", foreign_keys=[created_by], backref="created_wikis"
)
updater = db.relationship(
"User", foreign_keys=[updated_by], backref="updated_wikis"
)
# Linked entities relationships
linked_cards = db.relationship(
"Card",
secondary=wiki_entity_links,
primaryjoin=and_(
wiki_entity_links.c.wiki_id == id, wiki_entity_links.c.entity_type == "card"
),
secondaryjoin=wiki_entity_links.c.entity_id == Card.id,
viewonly=True,
)
linked_epics = db.relationship(
"Epic",
secondary=wiki_entity_links,
primaryjoin=and_(
wiki_entity_links.c.wiki_id == id, wiki_entity_links.c.entity_type == "epic"
),
secondaryjoin=wiki_entity_links.c.entity_id == Epic.id,
viewonly=True,
)
def to_dict(self):
"""Convert wiki to dictionary"""
return {
"id": self.id,
"name": self.name,
"slug": self.slug,
"content": self.content,
"summary": self.summary,
"category": self.category,
"board_id": self.board_id,
"tags": self.tags or [],
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f"<Wiki {self.name}>"

View file

@ -1,12 +1,9 @@
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from flask_jwt_extended import (create_access_token, create_refresh_token, from flask_jwt_extended import (create_access_token, create_refresh_token,
get_jwt_identity, jwt_required) get_jwt_identity, jwt_required)
from pydantic import ValidationError
from app import db from app import db
from app.celery import celery from app.models import User
from app.models import Order, OrderItem, Product, User
from app.schemas import ProductCreateRequest, ProductResponse
api_bp = Blueprint("api", __name__) api_bp = Blueprint("api", __name__)
@ -79,333 +76,3 @@ def get_current_user():
return jsonify({"error": "User not found"}), 404 return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict()), 200 return jsonify(user.to_dict()), 200
# Product Routes
@api_bp.route("/products", methods=["GET"])
def get_products():
"""Get all products"""
# time.sleep(5) # This adds a 5 second delay
products = Product.query.filter_by(is_active=True).all()
return jsonify([product.to_dict() for product in products]), 200
@api_bp.route("/products/<int:product_id>", methods=["GET"])
def get_product(product_id):
"""Get a single product"""
product = db.session.get(Product, product_id)
if not product:
return jsonify({"error": "Product not found"}), 404
return jsonify(product.to_dict()), 200
@api_bp.route("/products", methods=["POST"])
@jwt_required()
def create_product():
"""Create a new product (admin only)"""
user_id = int(get_jwt_identity())
user = db.session.get(User, user_id)
if not user or not user.is_admin:
return jsonify({"error": "Admin access required"}), 403
try:
# Validate request data using Pydantic schema
product_data = ProductCreateRequest(**request.get_json())
product = Product(
name=product_data.name,
description=product_data.description,
price=product_data.price,
stock=product_data.stock,
image_url=product_data.image_url,
)
db.session.add(product)
db.session.commit()
# Use Pydantic schema for response
response = ProductResponse.model_validate(product)
return jsonify(response.model_dump()), 201
except ValidationError as e:
print(f"Pydantic Validation Error: {e.errors()}")
return jsonify({"error": "Validation error", "details": e.errors()}), 400
@api_bp.route("/products/<int:product_id>", methods=["PUT"])
@jwt_required()
def update_product(product_id):
"""Update a product (admin only)"""
user_id = int(get_jwt_identity())
user = db.session.get(User, user_id)
if not user or not user.is_admin:
return jsonify({"error": "Admin access required"}), 403
product = db.session.get(Product, product_id)
if not product:
return jsonify({"error": "Product not found"}), 404
data = request.get_json()
product.name = data.get("name", product.name)
product.description = data.get("description", product.description)
product.price = data.get("price", product.price)
product.stock = data.get("stock", product.stock)
product.image_url = data.get("image_url", product.image_url)
product.is_active = data.get("is_active", product.is_active)
db.session.commit()
return jsonify(product.to_dict()), 200
@api_bp.route("/products/<int:product_id>", methods=["DELETE"])
@jwt_required()
def delete_product(product_id):
"""Delete a product (admin only)"""
user_id = int(get_jwt_identity())
user = db.session.get(User, user_id)
if not user or not user.is_admin:
return jsonify({"error": "Admin access required"}), 403
product = db.session.get(Product, product_id)
if not product:
return jsonify({"error": "Product not found"}), 404
db.session.delete(product)
db.session.commit()
return jsonify({"message": "Product deleted"}), 200
# Order Routes
@api_bp.route("/orders", methods=["GET"])
@jwt_required()
def get_orders():
"""Get all orders for current user"""
user_id = int(get_jwt_identity())
orders = Order.query.filter_by(user_id=user_id).all()
return jsonify([order.to_dict() for order in orders]), 200
@api_bp.route("/orders", methods=["POST"])
@jwt_required()
def create_order():
"""Create a new order"""
user_id = int(get_jwt_identity())
data = request.get_json()
if not data or not data.get("items"):
return jsonify({"error": "Order items are required"}), 400
total_amount = 0
order_items = []
for item_data in data["items"]:
product = db.session.get(Product, item_data["product_id"])
if not product:
return (
jsonify({"error": f'Product {item_data["product_id"]} not found'}),
404,
)
if product.stock < item_data["quantity"]:
return jsonify({"error": f"Insufficient stock for {product.name}"}), 400
item_total = product.price * item_data["quantity"]
total_amount += item_total
order_items.append(
{
"product": product,
"quantity": item_data["quantity"],
"price": product.price,
}
)
order = Order(
user_id=user_id,
total_amount=total_amount,
shipping_address=data.get("shipping_address"),
)
db.session.add(order)
db.session.flush()
for item_data in order_items:
order_item = OrderItem(
order_id=order.id,
product_id=item_data["product"].id,
quantity=item_data["quantity"],
price=item_data["price"],
)
item_data["product"].stock -= item_data["quantity"]
db.session.add(order_item)
db.session.commit()
return jsonify(order.to_dict()), 201
@api_bp.route("/orders/<int:order_id>", methods=["GET"])
@jwt_required()
def get_order(order_id):
"""Get a single order"""
user_id = int(get_jwt_identity())
order = db.session.get(Order, order_id)
if not order:
return jsonify({"error": "Order not found"}), 404
if order.user_id != user_id:
user = db.session.get(User, user_id)
if not user or not user.is_admin:
return jsonify({"error": "Access denied"}), 403
return jsonify(order.to_dict()), 200
# Celery Task Routes
@api_bp.route("/tasks/hello", methods=["POST"])
@jwt_required()
def trigger_hello_task():
"""Trigger the hello task"""
data = request.get_json() or {}
name = data.get("name", "World")
task = celery.send_task("tasks.print_hello", args=[name])
return (
jsonify(
{"message": "Hello task triggered", "task_id": task.id, "status": "pending"}
),
202,
)
@api_bp.route("/tasks/divide", methods=["POST"])
@jwt_required()
def trigger_divide_task():
"""Trigger the divide numbers task"""
data = request.get_json() or {}
x = data.get("x", 10)
y = data.get("y", 2)
task = celery.send_task("tasks.divide_numbers", args=[x, y])
return (
jsonify(
{
"message": "Divide task triggered",
"task_id": task.id,
"operation": f"{x} / {y}",
"status": "pending",
}
),
202,
)
@api_bp.route("/tasks/report", methods=["POST"])
@jwt_required()
def trigger_report_task():
"""Trigger the daily report task"""
task = celery.send_task("tasks.send_daily_report")
return (
jsonify(
{
"message": "Daily report task triggered",
"task_id": task.id,
"status": "pending",
}
),
202,
)
@api_bp.route("/tasks/stats", methods=["POST"])
@jwt_required()
def trigger_stats_task():
"""Trigger product statistics update task"""
data = request.get_json() or {}
product_id = data.get("product_id")
if product_id:
task = celery.send_task("tasks.update_product_statistics", args=[product_id])
message = f"Product statistics update triggered for product {product_id}"
else:
task = celery.send_task("tasks.update_product_statistics", args=[None])
message = "Product statistics update triggered for all products"
return jsonify({"message": message, "task_id": task.id, "status": "pending"}), 202
@api_bp.route("/tasks/long-running", methods=["POST"])
@jwt_required()
def trigger_long_running_task():
"""Trigger a long-running task"""
data = request.get_json() or {}
iterations = data.get("iterations", 10)
task = celery.send_task("tasks.long_running_task", args=[iterations])
return (
jsonify(
{
"message": f"Long-running task triggered with {iterations} iterations",
"task_id": task.id,
"status": "pending",
}
),
202,
)
@api_bp.route("/tasks/<task_id>", methods=["GET"])
@jwt_required()
def get_task_status(task_id):
"""Get the status of a Celery task"""
task_result = celery.AsyncResult(task_id)
response = {
"task_id": task_id,
"status": task_result.status,
"ready": task_result.ready(),
}
if task_result.ready():
if task_result.successful():
response["result"] = task_result.result
else:
response["error"] = str(task_result.result)
response["traceback"] = task_result.traceback
return jsonify(response), 200
@api_bp.route("/tasks/health", methods=["GET"])
def celery_health():
"""Check Celery health"""
try:
# Try to ping the worker
inspector = celery.control.inspect()
stats = inspector.stats()
if stats:
return (
jsonify(
{"status": "healthy", "workers": len(stats), "workers_info": stats}
),
200,
)
else:
return (
jsonify({"status": "unhealthy", "message": "No workers available"}),
503,
)
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500

View file

@ -6,8 +6,8 @@ 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
# fmt: off # fmt: off
from . import (boards, cards, checklists, comments, files, # noqa: F401 E402 from . import (boards, cards, checklists, comments, epics, # noqa: F401 E402
labels, lists) files, labels, lists, wikis)
# fmt: on # fmt: on

View file

@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
from flask_pydantic import validate from flask_pydantic import validate
from app import db from app import db
from app.decorators import get_current_user_id, load_board_owned from app.decorators import load_board_owned
from app.decorators.decorators import get_current_user_id
from app.models import Board, Card, CardLabel, Label, List from app.models import Board, Card, CardLabel, Label, List
from app.schemas import (BoardCreateRequest, BoardResponse, from app.schemas import (BoardCreateRequest, BoardResponse,
BoardWithDetailsResponse) BoardWithDetailsResponse)
@ -64,6 +65,12 @@ def get_board(board_id, board):
for checklist in card.checklists.all() for checklist in card.checklists.all()
] ]
# Add epic for this card
if card.epic:
card_dict["epic"] = card.epic.to_dict()
else:
card_dict["epic"] = None
cards_data.append(card_dict) cards_data.append(card_dict)
lists_data.append({**lst.to_dict(), "cards": cards_data}) lists_data.append({**lst.to_dict(), "cards": cards_data})

View file

@ -77,6 +77,12 @@ def get_card(card_id, card):
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 epic
if card.epic:
card_dict["epic"] = card.epic.to_dict()
else:
card_dict["epic"] = None
response = CardWithDetailsResponse(**card_dict) response = CardWithDetailsResponse(**card_dict)
return response.model_dump(), 200 return response.model_dump(), 200

View file

@ -0,0 +1,170 @@
"""Routes for Epic CRUD operations."""
from flask import request
from flask_jwt_extended import jwt_required
from flask_pydantic import validate
from app import db
from app.decorators import serialize_list_response, serialize_response
from app.models import Board, Card, Epic
from app.schemas import (CardResponse, EpicCreateRequest, EpicResponse,
EpicUpdateRequest)
from . import kanban_bp
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["GET"])
@jwt_required()
@serialize_list_response(EpicResponse)
def get_board_epics(board_id):
"""Get all epics for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
epics = Epic.query.filter_by(board_id=board_id).all()
return epics, 200
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["POST"])
@jwt_required()
@validate(body=EpicCreateRequest)
@serialize_response(EpicResponse)
def create_epic(board_id, body: EpicCreateRequest):
"""Create a new epic for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
epic = Epic(
board_id=board_id,
name=body.name,
description=body.description,
content=body.content,
color=body.color,
closed=False,
pos=body.pos,
depth_limit=body.depth_limit,
parent_epic_id=body.parent_epic_id,
completed_list_id=body.completed_list_id,
metrics={"card_count": 0, "completed_cards_count": 0},
)
db.session.add(epic)
db.session.commit()
return epic, 201
@kanban_bp.route("/epics/<int:epic_id>", methods=["GET"])
@jwt_required()
def get_epic(epic_id):
"""Get a specific epic with details."""
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
# Get cards for this epic
cards = Card.query.filter_by(epic_id=epic_id).all()
epic_dict = EpicResponse.model_validate(epic).model_dump()
epic_dict["cards"] = [card.to_dict() for card in cards]
return epic_dict, 200
def _update_epic_fields(epic: Epic, body: EpicUpdateRequest) -> None:
"""Update epic fields from request body."""
if body.name is not None:
epic.name = body.name
if body.description is not None:
epic.description = body.description
if body.content is not None:
epic.content = body.content
if body.color is not None:
epic.color = body.color
if body.closed is not None:
epic.closed = body.closed
if body.pos is not None:
epic.pos = body.pos
if body.depth_limit is not None:
epic.depth_limit = body.depth_limit
if body.parent_epic_id is not None:
epic.parent_epic_id = body.parent_epic_id
if body.completed_list_id is not None:
epic.completed_list_id = body.completed_list_id
@kanban_bp.route("/epics/<int:epic_id>", methods=["PUT"])
@jwt_required()
@validate(body=EpicUpdateRequest)
@serialize_response(EpicResponse)
def update_epic(epic_id, body: EpicUpdateRequest):
"""Update an epic."""
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
_update_epic_fields(epic, body)
db.session.commit()
return epic, 200
@kanban_bp.route("/epics/<int:epic_id>", methods=["DELETE"])
@jwt_required()
def delete_epic(epic_id):
"""Delete an epic."""
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
# Unlink all cards from this epic
Card.query.filter_by(epic_id=epic_id).update({"epic_id": None})
# Delete epic
db.session.delete(epic)
db.session.commit()
return {"message": "Epic deleted successfully"}, 200
@kanban_bp.route("/cards/<int:card_id>/epics", methods=["POST"])
@jwt_required()
@serialize_response(CardResponse)
def add_epic_to_card(card_id):
"""Link an epic to a card."""
card = db.session.get(Card, card_id)
if not card:
return {"error": "Card not found"}, 404
data = request.get_json()
epic_id = data.get("epic_id")
if not epic_id:
return {"error": "epic_id is required"}, 400
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
card.epic_id = epic_id
db.session.commit()
return card, 200
@kanban_bp.route("/cards/<int:card_id>/epics/<int:epic_id>", methods=["DELETE"])
@jwt_required()
def remove_epic_from_card(card_id, epic_id):
"""Unlink an epic from a card."""
card = db.session.get(Card, card_id)
if not card:
return {"error": "Card not found"}, 404
if card.epic_id != epic_id:
return {"error": "Card is not linked to this epic"}, 400
card.epic_id = None
db.session.commit()
return {"message": "Epic unlinked from card successfully"}, 200

View file

@ -0,0 +1,232 @@
"""Routes for Wiki CRUD operations."""
import re
from flask_jwt_extended import get_jwt_identity, jwt_required
from flask_pydantic import validate
from sqlalchemy import and_
from sqlalchemy import delete as sql_delete
from sqlalchemy import select
from app import db
from app.decorators import serialize_list_response, serialize_response
from app.models import Board, Wiki, wiki_entity_links
from app.models.card import Card
from app.models.epic import Epic
from app.schemas import (CardResponse, EpicResponse, WikiCreateRequest,
WikiLinkRequest, WikiResponse, WikiUpdateRequest)
from . import kanban_bp
def generate_slug(name):
"""Generate URL-friendly slug from name."""
slug = name.lower()
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"\s+", "-", slug)
slug = slug.strip("-")
return slug
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["GET"])
@jwt_required()
@serialize_list_response(WikiResponse)
def get_board_wikis(board_id):
"""Get all wikis for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
wikis = Wiki.query.filter_by(board_id=board_id).all()
return wikis, 200
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["POST"])
@jwt_required()
@validate(body=WikiCreateRequest)
@serialize_response(WikiResponse)
def create_wiki(board_id, body: WikiCreateRequest):
"""Create a new wiki for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
# Generate slug if not provided
slug = body.slug
if not slug:
slug = generate_slug(body.name)
# Ensure uniqueness
counter = 1
original_slug = slug
while Wiki.query.filter_by(slug=slug).first():
slug = f"{original_slug}-{counter}"
counter += 1
wiki = Wiki(
board_id=board_id,
name=body.name,
slug=slug,
content=body.content,
summary=body.summary,
category=body.category,
tags=body.tags,
created_by=get_jwt_identity(),
updated_by=get_jwt_identity(),
)
db.session.add(wiki)
db.session.commit()
return wiki, 201
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["GET"])
@jwt_required()
def get_wiki(wiki_id):
"""Get a specific wiki with details including linked entities."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Convert wiki to dict and add linked entities
wiki_dict = wiki.to_dict()
# Add linked cards
linked_cards = [
CardResponse.model_validate(card).model_dump() for card in wiki.linked_cards
]
# Add linked epics
linked_epics = [
EpicResponse.model_validate(epic).model_dump() for epic in wiki.linked_epics
]
wiki_dict["linked_cards"] = linked_cards
wiki_dict["linked_epics"] = linked_epics
return wiki_dict, 200
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["PUT"])
@jwt_required()
@validate(body=WikiUpdateRequest)
@serialize_response(WikiResponse)
def update_wiki(wiki_id, body: WikiUpdateRequest):
"""Update a wiki."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Update fields
if body.name is not None:
wiki.name = body.name
if body.slug is not None:
wiki.slug = body.slug
if body.content is not None:
wiki.content = body.content
if body.summary is not None:
wiki.summary = body.summary
if body.category is not None:
wiki.category = body.category
if body.tags is not None:
wiki.tags = body.tags
wiki.updated_by = get_jwt_identity()
db.session.commit()
return wiki, 200
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["DELETE"])
@jwt_required()
def delete_wiki(wiki_id):
"""Delete a wiki."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Delete wiki (cascades to wiki_entity_links)
db.session.delete(wiki)
db.session.commit()
return {"message": "Wiki deleted successfully"}, 200
@kanban_bp.route("/wikis/<int:wiki_id>/links", methods=["POST"])
@jwt_required()
@validate(body=WikiLinkRequest)
def create_wiki_link(wiki_id, body: WikiLinkRequest):
"""Link a wiki to another entity (card, epic, list, or board)."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Check if entity exists
entity = None
if body.entity_type == "card":
entity = db.session.get(Card, body.entity_id)
elif body.entity_type == "epic":
entity = db.session.get(Epic, body.entity_id)
elif body.entity_type == "list":
from app.models.list_model import List
entity = db.session.get(List, body.entity_id)
elif body.entity_type == "board":
entity = db.session.get(Board, body.entity_id)
if not entity:
return {"error": f"{body.entity_type.capitalize()} not found"}, 404
# Check if link already exists
existing_link = db.session.scalars(
select(wiki_entity_links).where(
and_(
wiki_entity_links.c.wiki_id == wiki_id,
wiki_entity_links.c.entity_type == body.entity_type,
wiki_entity_links.c.entity_id == body.entity_id,
)
)
).first()
if existing_link:
return {"error": "Link already exists"}, 400
# Create link using core insert
stmt = wiki_entity_links.insert().values(
wiki_id=wiki_id,
entity_type=body.entity_type,
entity_id=body.entity_id,
linked_by=get_jwt_identity(),
)
db.session.execute(stmt)
db.session.commit()
return {"message": "Link created successfully"}, 201
@kanban_bp.route("/wikis/<int:wiki_id>/links", methods=["DELETE"])
@jwt_required()
@validate(body=WikiLinkRequest)
def delete_wiki_link(wiki_id, body: WikiLinkRequest):
"""Delete a wiki entity link."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Delete link using core delete
stmt = sql_delete(wiki_entity_links).where(
and_(
wiki_entity_links.c.wiki_id == wiki_id,
wiki_entity_links.c.entity_type == body.entity_type,
wiki_entity_links.c.entity_id == body.entity_id,
)
)
result = db.session.execute(stmt)
if result.rowcount == 0:
return {"error": "Link not found"}, 404
db.session.commit()
return {"message": "Link deleted successfully"}, 200

View file

@ -7,18 +7,19 @@ from app.schemas.check_item import CheckItemCreateRequest, CheckItemResponse
from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse
from app.schemas.comment import (CommentCreateRequest, CommentResponse, from app.schemas.comment import (CommentCreateRequest, CommentResponse,
CommentWithUserResponse) CommentWithUserResponse)
from app.schemas.epic import (EpicCreateRequest, EpicResponse,
EpicUpdateRequest, EpicWithCardsResponse)
from app.schemas.file_attachment import (FileAttachmentWithUrlResponse, from app.schemas.file_attachment import (FileAttachmentWithUrlResponse,
FileDeleteResponse, FileUploadRequest, FileDeleteResponse, FileUploadRequest,
MultipleFilesResponse, MultipleFilesResponse,
ValidationError) ValidationError)
from app.schemas.label import LabelCreateRequest, LabelResponse from app.schemas.label import LabelCreateRequest, LabelResponse
from app.schemas.list_model import ListCreateRequest, ListResponse from app.schemas.list_model import ListCreateRequest, ListResponse
from app.schemas.product import ProductCreateRequest, ProductResponse from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink,
WikiLinkRequest, WikiResponse, WikiUpdateRequest)
__all__ = [ __all__ = [
# Product schemas # Product schemas
"ProductCreateRequest",
"ProductResponse",
# Board schemas # Board schemas
"BoardCreateRequest", "BoardCreateRequest",
"BoardResponse", "BoardResponse",
@ -50,4 +51,15 @@ __all__ = [
"BoardWithDetailsResponse", "BoardWithDetailsResponse",
"CardWithDetailsResponse", "CardWithDetailsResponse",
"CommentWithUserResponse", "CommentWithUserResponse",
# Epic schemas
"EpicCreateRequest",
"EpicResponse",
"EpicUpdateRequest",
"EpicWithCardsResponse",
# Wiki schemas
"WikiCreateRequest",
"WikiResponse",
"WikiUpdateRequest",
"WikiEntityLink",
"WikiLinkRequest",
] ]

View file

@ -47,6 +47,7 @@ class CardResponse(BaseModel):
"pos": 65535.0, "pos": 65535.0,
"list_id": 1, "list_id": 1,
"board_id": 1, "board_id": 1,
"epic_id": 1,
"due": "2024-12-31T23:59:59", "due": "2024-12-31T23:59:59",
"due_complete": False, "due_complete": False,
"badges": {"votes": 0}, "badges": {"votes": 0},
@ -64,6 +65,7 @@ class CardResponse(BaseModel):
pos: float pos: float
list_id: int list_id: int
board_id: int board_id: int
epic_id: Optional[int]
due: Optional[datetime] = None due: Optional[datetime] = None
due_complete: bool due_complete: bool
badges: Optional[Dict[str, Any]] = None badges: Optional[Dict[str, Any]] = None
@ -115,3 +117,4 @@ class CardWithDetailsResponse(BaseModel):
labels: List[Dict[str, Any]] = Field(default_factory=list) labels: List[Dict[str, Any]] = Field(default_factory=list)
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

139
backend/app/schemas/epic.py Normal file
View file

@ -0,0 +1,139 @@
"""Pydantic schemas for Epic model"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
class EpicCreateRequest(BaseModel):
"""Schema for creating a new epic"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "User Authentication",
"description": "Implement login and registration",
"content": [
{"type": "paragraph", "children": [{"text": "Details..."}]}
],
"color": "#3b82f6",
"pos": 1.0,
"depth_limit": 5,
"parent_epic_id": None,
"completed_list_id": 5,
}
}
)
name: str = Field(..., min_length=1, max_length=200, description="Epic name")
description: Optional[str] = Field(None, description="Epic description")
content: Optional[Any] = Field(None, description="Rich text content")
color: Optional[str] = Field(None, max_length=7, description="Hex color code")
pos: Optional[float] = Field(None, description="Position for ordering")
depth_limit: Optional[int] = Field(
None, ge=1, le=10, description="Maximum depth for hierarchy"
)
parent_epic_id: Optional[int] = Field(
None, description="Parent epic ID for nesting"
)
completed_list_id: Optional[int] = Field(
None, description="List ID that marks cards as completed"
)
class EpicResponse(BaseModel):
"""Schema for epic response"""
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "User Authentication",
"description": "Implement login and registration",
"content": [
{"type": "paragraph", "children": [{"text": "Details..."}]}
],
"color": "#3b82f6",
"closed": False,
"pos": 1.0,
"depth_limit": 5,
"board_id": 1,
"parent_epic_id": None,
"metrics": {"card_count": 5, "progress": 60},
"date_last_activity": "2024-01-15T10:30:00",
"created_at": "2024-01-01T10:00:00",
"updated_at": "2024-01-15T10:30:00",
}
},
)
id: int
name: str
description: Optional[str] = None
content: Optional[Any] = None
color: Optional[str] = None
closed: bool
pos: Optional[float] = None
depth_limit: Optional[int] = None
board_id: int
parent_epic_id: Optional[int] = None
completed_list_id: Optional[int] = None
metrics: Optional[Dict[str, Any]] = None
date_last_activity: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class EpicUpdateRequest(BaseModel):
"""Schema for updating an epic"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Updated Epic Name",
"description": "Updated description",
"content": [
{"type": "paragraph", "children": [{"text": "Updated..."}]}
],
"color": "#ef4444",
"closed": True,
"pos": 2.0,
"depth_limit": 3,
"parent_epic_id": 2,
"completed_list_id": 5,
}
}
)
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
content: Optional[Any] = None
color: Optional[str] = Field(None, max_length=7)
closed: Optional[bool] = None
pos: Optional[float] = None
depth_limit: Optional[int] = Field(None, ge=1, le=10)
parent_epic_id: Optional[int] = None
completed_list_id: Optional[int] = None
class EpicWithCardsResponse(BaseModel):
"""Schema for epic response with cards"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: Optional[str] = None
content: Optional[Any] = None
color: Optional[str] = None
closed: bool
pos: Optional[float] = None
depth_limit: Optional[int] = None
board_id: int
parent_epic_id: Optional[int] = None
metrics: Optional[Dict[str, Any]] = None
date_last_activity: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
cards: List[Dict[str, Any]] = Field(default_factory=list)

View file

@ -1,71 +0,0 @@
"""Pydantic schemas for Product model"""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ProductCreateRequest(BaseModel):
"""Schema for creating a new product"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl made from oak",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
}
}
)
name: str = Field(..., min_length=1, max_length=200, description="Product name")
description: Optional[str] = Field(None, description="Product description")
price: Decimal = Field(
..., gt=0, description="Product price (must be greater than 0)"
)
stock: int = Field(default=0, ge=0, description="Product stock quantity")
image_url: Optional[str] = Field(
None, max_length=500, description="Product image URL"
)
@field_validator("price")
@classmethod
def validate_price(cls, v: Decimal) -> Decimal:
"""Validate that price has at most 2 decimal places"""
if v.as_tuple().exponent < -2:
raise ValueError("Price must have at most 2 decimal places")
return v
class ProductResponse(BaseModel):
"""Schema for product response"""
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl made from oak",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
"is_active": True,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-01-15T10:30:00",
}
},
)
id: int
name: str
description: Optional[str] = None
price: float
stock: int
image_url: Optional[str] = None
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

174
backend/app/schemas/wiki.py Normal file
View file

@ -0,0 +1,174 @@
"""Pydantic schemas for Wiki model"""
from datetime import datetime
from typing import Any, List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.card import CardResponse
from app.schemas.epic import EpicResponse
class WikiCreateRequest(BaseModel):
"""Schema for creating a new wiki"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Security Guidelines",
"slug": "security-guidelines",
"content": [
{"type": "paragraph", "children": [{"text": "Security rules..."}]}
],
"summary": "Brief security overview",
"category": "Documentation",
"tags": ["security", "guidelines"],
}
}
)
name: str = Field(..., min_length=1, max_length=200, description="Wiki name")
slug: Optional[str] = Field(None, max_length=200, description="URL-friendly slug")
content: Any = Field(..., description="Rich text content")
summary: Optional[str] = Field(None, description="Brief summary")
category: Optional[str] = Field(None, max_length=100, description="Wiki category")
tags: Optional[List[str]] = Field(None, description="Wiki tags")
@field_validator("slug")
@classmethod
def validate_slug(cls, v):
"""Validate slug is URL-friendly."""
if v and not v.islower():
raise ValueError("Slug must be lowercase")
if v and not all(c.isalnum() or c == "-" for c in v):
raise ValueError(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
return v
class WikiResponse(BaseModel):
"""Schema for wiki response"""
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "Security Guidelines",
"slug": "security-guidelines",
"content": [
{"type": "paragraph", "children": [{"text": "Security rules..."}]}
],
"summary": "Brief security overview",
"category": "Documentation",
"tags": ["security", "guidelines"],
"board_id": 1,
"created_by": 1,
"updated_by": 1,
"created_at": "2024-01-01T10:00:00",
"updated_at": "2024-01-15T10:30:00",
"linked_cards": [],
"linked_epics": [],
}
},
)
id: int
name: str
slug: Optional[str] = None
content: Any
summary: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
board_id: int
created_by: Optional[int] = None
updated_by: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
linked_cards: Optional[List[CardResponse]] = None
linked_epics: Optional[List[EpicResponse]] = None
class WikiUpdateRequest(BaseModel):
"""Schema for updating a wiki"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Updated Security Guidelines",
"slug": "updated-security-guidelines",
"content": [
{"type": "paragraph", "children": [{"text": "Updated..."}]}
],
"summary": "Updated summary",
"category": "Updated Category",
"tags": ["security", "updated"],
}
}
)
name: Optional[str] = Field(None, min_length=1, max_length=200)
slug: Optional[str] = Field(None, max_length=200)
content: Optional[Any] = None
summary: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
tags: Optional[List[str]] = None
@field_validator("slug")
@classmethod
def validate_slug(cls, v):
"""Validate slug is URL-friendly."""
if v and not v.islower():
raise ValueError("Slug must be lowercase")
if v and not all(c.isalnum() or c == "-" for c in v):
raise ValueError(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
return v
class WikiEntityLink(BaseModel):
"""Schema for wiki entity link response"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"wiki_id": 1,
"entity_type": "card",
"entity_id": 5,
"created_at": "2024-01-15T10:30:00",
"linked_by": 1,
}
}
)
wiki_id: int
entity_type: str
entity_id: int
created_at: Optional[datetime] = None
linked_by: Optional[int] = None
class WikiLinkRequest(BaseModel):
"""Schema for creating a wiki entity link"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"entity_type": "card",
"entity_id": 5,
}
}
)
entity_type: str = Field(..., description="Entity type (card, epic, list, board)")
entity_id: int = Field(..., description="Entity ID")
@field_validator("entity_type")
@classmethod
def validate_entity_type(cls, v):
"""Validate entity type is supported."""
valid_types = ["card", "epic", "list", "board"]
if v not in valid_types:
raise ValueError(f"entity_type must be one of: {', '.join(valid_types)}")
return v

View file

@ -54,6 +54,7 @@ class StorageExtension:
access_key=app.config["MINIO_ACCESS_KEY"], access_key=app.config["MINIO_ACCESS_KEY"],
secret_key=app.config["MINIO_SECRET_KEY"], secret_key=app.config["MINIO_SECRET_KEY"],
secure=app.config["MINIO_USE_SSL"], secure=app.config["MINIO_USE_SSL"],
region=app.config["MINIO_REGION"],
) )
# Validate connection by listing buckets # Validate connection by listing buckets
@ -93,17 +94,11 @@ class StorageExtension:
] ]
for bucket_name in buckets: for bucket_name in buckets:
try: if self.client.bucket_exists(bucket_name):
if not self.client.bucket_exists(bucket_name): app.logger.debug(f"MinIO bucket exists: {bucket_name}")
self.client.make_bucket(bucket_name)
app.logger.info(f"Created MinIO bucket: {bucket_name}")
else: else:
app.logger.debug(f"MinIO bucket already exists: {bucket_name}") app.logger.error(f"MinIO bucket does not exists: {bucket_name}")
except S3Error as e: raise RuntimeError(f"Failed to create MinIO bucket '{bucket_name}'")
app.logger.error(f"Failed to ensure bucket {bucket_name}: {e}")
raise RuntimeError(
f"Failed to create MinIO bucket '{bucket_name}': {e}"
) from e
def get_client(self) -> Minio: def get_client(self) -> Minio:
""" """

View file

@ -0,0 +1,106 @@
"""add epic and wiki models
Revision ID: 6fc439155ced
Revises: e9515e29ef8b
Create Date: 2026-03-22 00:24:26.645867
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '6fc439155ced'
down_revision = 'e9515e29ef8b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('epics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('closed', sa.Boolean(), nullable=True),
sa.Column('pos', sa.Float(), nullable=True),
sa.Column('depth_limit', sa.Integer(), nullable=True),
sa.Column('board_id', sa.Integer(), nullable=False),
sa.Column('parent_epic_id', sa.Integer(), nullable=True),
sa.Column('date_last_activity', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['parent_epic_id'], ['epics.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_epics_board_id'), ['board_id'], unique=False)
batch_op.create_index(batch_op.f('ix_epics_closed'), ['closed'], unique=False)
batch_op.create_index(batch_op.f('ix_epics_name'), ['name'], unique=False)
op.create_table('wikis',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=True),
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('summary', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('board_id', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('updated_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('tags', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_wikis_board_id'), ['board_id'], unique=False)
batch_op.create_index(batch_op.f('ix_wikis_name'), ['name'], unique=False)
batch_op.create_index(batch_op.f('ix_wikis_slug'), ['slug'], unique=False)
op.create_table('wiki_entity_links',
sa.Column('wiki_id', sa.Integer(), nullable=False),
sa.Column('entity_type', sa.String(length=50), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('linked_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['linked_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['wiki_id'], ['wikis.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('wiki_id')
)
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.add_column(sa.Column('epic_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_cards_epic_id'), ['epic_id'], unique=False)
batch_op.create_foreign_key(None, 'epics', ['epic_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_cards_epic_id'))
batch_op.drop_column('epic_id')
op.drop_table('wiki_entity_links')
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_wikis_slug'))
batch_op.drop_index(batch_op.f('ix_wikis_name'))
batch_op.drop_index(batch_op.f('ix_wikis_board_id'))
op.drop_table('wikis')
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_epics_name'))
batch_op.drop_index(batch_op.f('ix_epics_closed'))
batch_op.drop_index(batch_op.f('ix_epics_board_id'))
op.drop_table('epics')
# ### end Alembic commands ###

View file

@ -0,0 +1,36 @@
"""epic_add completion list id
Revision ID: 84551733ccb0
Revises: 6fc439155ced
Create Date: 2026-03-28 21:54:31.562023
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '84551733ccb0'
down_revision = '6fc439155ced'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.add_column(sa.Column('completed_list_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_epics_completed_list_id'), ['completed_list_id'], unique=False)
batch_op.create_foreign_key(None, 'lists', ['completed_list_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_epics_completed_list_id'))
batch_op.drop_column('completed_list_id')
# ### end Alembic commands ###

View file

@ -0,0 +1,74 @@
"""remove products tables
Revision ID: a9709e7ed22d
Revises: 84551733ccb0
Create Date: 2026-04-12 12:55:32.064593
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'a9709e7ed22d'
down_revision = '84551733ccb0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_products_name'))
op.drop_table('products')
op.drop_table('order_items')
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_orders_status'))
op.drop_table('orders')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('orders',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('status', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('total_amount', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.Column('shipping_address', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('orders_user_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('orders_pkey'))
)
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_orders_status'), ['status'], unique=False)
op.create_table('order_items',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('order_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('product_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('quantity', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['order_id'], ['orders.id'], name=op.f('order_items_order_id_fkey')),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], name=op.f('order_items_product_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('order_items_pkey'))
)
op.create_table('products',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=False),
sa.Column('stock', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('image_url', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('products_pkey'))
)
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_products_name'), ['name'], unique=False)
# ### end Alembic commands ###

View file

@ -1,5 +1,6 @@
"""Pytest configuration and fixtures""" """Pytest configuration and fixtures"""
import logging import logging
import time
from io import BytesIO from io import BytesIO
import pytest import pytest
@ -7,8 +8,7 @@ from faker import Faker
from PIL import Image from PIL import Image
from app import create_app, db from app import create_app, db
from app.models import (Board, Card, Comment, List, Order, OrderItem, Product, from app.models import Board, Card, Comment, List, User
User)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
fake = Faker() fake = Faker()
@ -116,7 +116,7 @@ def regular_user(db_session):
"""Create a regular user for testing""" """Create a regular user for testing"""
user = User( user = User(
email=fake.email(), email=fake.email(),
username=fake.user_name(), username=f"{fake.user_name()}_{int(time.time() * 1000)}",
first_name=fake.first_name(), first_name=fake.first_name(),
last_name=fake.last_name(), last_name=fake.last_name(),
is_admin=False, is_admin=False,
@ -145,39 +145,6 @@ def inactive_user(db_session):
return user return user
@pytest.fixture
def product(db_session):
"""Create a product for testing"""
product = Product(
name=fake.sentence(nb_words=4)[:-1], # Remove period
description=fake.paragraph(),
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
stock=fake.pyint(min_value=0, max_value=100),
image_url=fake.url(),
)
db_session.add(product)
db_session.commit()
return product
@pytest.fixture
def products(db_session):
"""Create multiple products for testing"""
products = []
for _ in range(5):
product = Product(
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
stock=fake.pyint(min_value=20, max_value=100),
image_url=fake.url(),
)
db_session.add(product)
products.append(product)
db_session.commit()
return products
@pytest.fixture @pytest.fixture
def auth_headers(client, regular_user): def auth_headers(client, regular_user):
"""Get authentication headers for a regular user""" """Get authentication headers for a regular user"""
@ -203,33 +170,6 @@ def admin_headers(client, admin_user):
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def order(db_session, regular_user, products):
print("-----order-created------")
"""Create an order for testing"""
order = Order(
user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address()
)
db_session.add(order)
db_session.flush()
total_amount = 0
for i, product in enumerate(products[:2]):
quantity = fake.pyint(min_value=1, max_value=5)
order_item = OrderItem(
order_id=order.id,
product_id=product.id,
quantity=quantity,
price=product.price,
)
total_amount += float(product.price) * quantity
db_session.add(order_item)
order.total_amount = total_amount
db_session.commit()
return order
# ============ File Upload Fixtures ============ # ============ File Upload Fixtures ============

View file

@ -0,0 +1,702 @@
"""Integration tests for Epic API routes"""
import pytest
from app import db
from app.models import Epic
@pytest.mark.integration
class TestEpicRoutes:
"""Test Epic API routes"""
# ========== GET /boards/<board_id>/epics ==========
def test_get_board_epics_success(
self, client, db_session, auth_headers, test_board
):
"""Test getting all epics for a board"""
# Create epics for the board
epic1 = Epic(name="Epic 1", board_id=test_board.id, color="#3b82f6", pos=1.0)
epic2 = Epic(name="Epic 2", board_id=test_board.id, color="#ef4444", pos=2.0)
db_session.add(epic1)
db_session.add(epic2)
db_session.commit()
response = client.get(
f"/api/boards/{test_board.id}/epics", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert len(data) == 2
assert data[0]["name"] == "Epic 1"
assert data[1]["name"] == "Epic 2"
def test_get_board_epics_empty(self, client, db_session, auth_headers, test_board):
"""Test getting epics when none exist for board"""
response = client.get(
f"/api/boards/{test_board.id}/epics", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert len(data) == 0
def test_get_board_epics_board_not_found(self, client, db_session, auth_headers):
"""Test getting epics for non-existent board"""
response = client.get("/api/boards/99999/epics", headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_get_board_epics_unauthorized(self, client, db_session, test_board):
"""Test getting epics without authentication"""
response = client.get(f"/api/boards/{test_board.id}/epics")
assert response.status_code == 401
# ========== POST /boards/<board_id>/epics ==========
def test_create_epic_success(
self, client, db_session, regular_user, auth_headers, test_board
):
"""Test creating a new epic successfully"""
epic_data = {
"name": "User Authentication",
"description": "Implement login and registration",
"content": [{"type": "paragraph", "children": [{"text": "Details..."}]}],
"color": "#3b82f6",
"pos": 1.0,
"depth_limit": 5,
}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "User Authentication"
assert data["description"] == "Implement login and registration"
assert data["color"] == "#3b82f6"
assert data["pos"] == 1.0
assert data["depth_limit"] == 5
assert data["closed"] is False
assert "id" in data
assert data["metrics"] == {"card_count": 0, "completed_cards_count": 0}
def test_create_epic_minimal_data(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with only required fields"""
epic_data = {"name": "Minimal Epic"}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "Minimal Epic"
assert data["description"] is None
assert data["color"] is None
assert data["pos"] is None
assert data["depth_limit"] == 5 # Model default value
def test_create_epic_with_parent(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with parent epic"""
# Create parent epic
parent_epic = Epic(name="Parent Epic", board_id=test_board.id)
db_session.add(parent_epic)
db_session.commit()
epic_data = {
"name": "Child Epic",
"parent_epic_id": parent_epic.id,
}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["parent_epic_id"] == parent_epic.id
def test_create_epic_with_completed_list(
self, client, db_session, auth_headers, test_board, test_list
):
"""Test creating epic with completed list"""
epic_data = {
"name": "Epic with Completed List",
"completed_list_id": test_list.id,
}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["completed_list_id"] == test_list.id
def test_create_epic_board_not_found(self, client, db_session, auth_headers):
"""Test creating epic for non-existent board"""
epic_data = {"name": "Test Epic"}
response = client.post(
"/api/boards/99999/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_create_epic_unauthorized(self, client, db_session, test_board):
"""Test creating epic without authentication"""
epic_data = {"name": "Test Epic"}
response = client.post(
f"/api/boards/{test_board.id}/epics",
json=epic_data,
)
assert response.status_code == 401
def test_create_epic_missing_name(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic without name"""
epic_data = {"description": "No name provided"}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 400
def test_create_epic_name_too_long(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with name exceeding max length"""
epic_data = {"name": "A" * 201}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 400
def test_create_epic_invalid_depth_limit(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with invalid depth limit"""
epic_data = {"name": "Test Epic", "depth_limit": 15}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 400
def test_create_epic_invalid_color(
self, client, db_session, auth_headers, test_board
):
"""Test creating epic with invalid color code"""
epic_data = {"name": "Test Epic", "color": "invalid-color"}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
json=epic_data,
)
assert response.status_code == 400
# ========== GET /epics/<epic_id> ==========
def test_get_epic_success(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test getting a specific epic with details"""
# Create epic and link card
epic = Epic(name="Test Epic", board_id=test_board.id, color="#3b82f6")
db_session.add(epic)
db_session.commit()
test_card.epic_id = epic.id
db_session.commit()
response = client.get(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data["id"] == epic.id
assert data["name"] == "Test Epic"
assert "cards" in data
assert len(data["cards"]) == 1
assert data["cards"][0]["id"] == test_card.id
def test_get_epic_with_no_cards(self, client, db_session, auth_headers, test_board):
"""Test getting epic with no cards"""
epic = Epic(name="Empty Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.get(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data["id"] == epic.id
assert "cards" in data
assert len(data["cards"]) == 0
def test_get_epic_not_found(self, client, db_session, auth_headers):
"""Test getting non-existent epic"""
response = client.get("/api/epics/99999", headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_get_epic_unauthorized(self, client, db_session, test_board):
"""Test getting epic without authentication"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.get(f"/api/epics/{epic.id}")
assert response.status_code == 401
# ========== PUT /epics/<epic_id> ==========
def test_update_epic_success(self, client, db_session, auth_headers, test_board):
"""Test updating an epic successfully"""
epic = Epic(
name="Original Name",
board_id=test_board.id,
color="#3b82f6",
closed=False,
)
db_session.add(epic)
db_session.commit()
update_data = {
"name": "Updated Name",
"description": "Updated description",
"color": "#ef4444",
"closed": True,
"pos": 5.0,
"depth_limit": 3,
}
response = client.put(
f"/api/epics/{epic.id}",
headers=auth_headers,
json=update_data,
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Name"
assert data["description"] == "Updated description"
assert data["color"] == "#ef4444"
assert data["closed"] is True
assert data["pos"] == 5.0
assert data["depth_limit"] == 3
def test_update_epic_partial(self, client, db_session, auth_headers, test_board):
"""Test updating epic with partial data"""
epic = Epic(name="Original Name", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
update_data = {"name": "Updated Name"}
response = client.put(
f"/api/epics/{epic.id}",
headers=auth_headers,
json=update_data,
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Name"
# Other fields should remain unchanged
assert data["description"] is None
def test_update_epic_content(self, client, db_session, auth_headers, test_board):
"""Test updating epic content"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
new_content = [{"type": "heading", "children": [{"text": "Updated Content"}]}]
response = client.put(
f"/api/epics/{epic.id}",
headers=auth_headers,
json={"content": new_content},
)
assert response.status_code == 200
data = response.get_json()
assert data["content"] == new_content
def test_update_epic_not_found(self, client, db_session, auth_headers):
"""Test updating non-existent epic"""
response = client.put(
"/api/epics/99999",
headers=auth_headers,
json={"name": "Updated"},
)
assert response.status_code == 404
def test_update_epic_unauthorized(self, client, db_session, test_board):
"""Test updating epic without authentication"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.put(f"/api/epics/{epic.id}", json={"name": "Updated"})
assert response.status_code == 401
def test_update_epic_invalid_name(
self, client, db_session, auth_headers, test_board
):
"""Test updating epic with invalid name"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.put(
f"/api/epics/{epic.id}",
headers=auth_headers,
json={"name": ""},
)
assert response.status_code == 400
# ========== DELETE /epics/<epic_id> ==========
def test_delete_epic_success(self, client, db_session, auth_headers, test_board):
"""Test deleting an epic successfully"""
epic = Epic(name="To Delete", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
epic_id = epic.id
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "message" in data
# Verify epic is deleted
deleted_epic = db.session.get(Epic, epic_id)
assert deleted_epic is None
def test_delete_epic_with_cards(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test deleting epic unlinks cards"""
epic = Epic(name="Epic with Cards", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
# Link card to epic
test_card.epic_id = epic.id
db_session.commit()
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
# Verify card is unlinked
db.session.refresh(test_card)
assert test_card.epic_id is None
def test_delete_epic_not_found(self, client, db_session, auth_headers):
"""Test deleting non-existent epic"""
response = client.delete("/api/epics/99999", headers=auth_headers)
assert response.status_code == 404
def test_delete_epic_unauthorized(self, client, db_session, test_board):
"""Test deleting epic without authentication"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.delete(f"/api/epics/{epic.id}")
assert response.status_code == 401
# ========== POST /cards/<card_id>/epics ==========
def test_add_epic_to_card_success(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test linking an epic to a card successfully"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={"epic_id": epic.id},
)
assert response.status_code == 200
data = response.get_json()
assert data["epic_id"] == epic.id
# Verify card is linked
db.session.refresh(test_card)
assert test_card.epic_id == epic.id
def test_add_epic_to_card_already_linked(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test linking epic to card that already has an epic"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
test_card.epic_id = epic.id
db_session.commit()
# Try to link to same epic again (should just update)
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={"epic_id": epic.id},
)
assert response.status_code == 200
def test_add_epic_to_card_replacement(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test replacing epic on card"""
epic1 = Epic(name="Epic 1", board_id=test_board.id)
epic2 = Epic(name="Epic 2", board_id=test_board.id)
db_session.add(epic1)
db_session.add(epic2)
db_session.commit()
test_card.epic_id = epic1.id
db_session.commit()
# Replace with epic2
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={"epic_id": epic2.id},
)
assert response.status_code == 200
# Verify card is linked to epic2
db.session.refresh(test_card)
assert test_card.epic_id == epic2.id
def test_add_epic_to_card_not_found(self, client, db_session, auth_headers):
"""Test linking epic to non-existent card"""
response = client.post(
"/api/cards/99999/epics",
headers=auth_headers,
json={"epic_id": 1},
)
assert response.status_code == 404
def test_add_epic_to_card_epic_not_found(
self, client, db_session, auth_headers, test_card
):
"""Test linking non-existent epic to card"""
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={"epic_id": 99999},
)
assert response.status_code == 404
def test_add_epic_to_card_missing_epic_id(
self, client, db_session, auth_headers, test_card
):
"""Test linking epic to card without epic_id"""
response = client.post(
f"/api/cards/{test_card.id}/epics",
headers=auth_headers,
json={},
)
assert response.status_code == 400
data = response.get_json()
assert "required" in data["error"].lower()
def test_add_epic_to_card_unauthorized(self, client, db_session, test_card):
"""Test linking epic to card without authentication"""
response = client.post(
f"/api/cards/{test_card.id}/epics",
json={"epic_id": 1},
)
assert response.status_code == 401
# ========== DELETE /cards/<card_id>/epics/<epic_id> ==========
def test_remove_epic_from_card_success(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test unlinking an epic from a card successfully"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
test_card.epic_id = epic.id
db_session.commit()
response = client.delete(
f"/api/cards/{test_card.id}/epics/{epic.id}",
headers=auth_headers,
)
assert response.status_code == 200
data = response.get_json()
assert "message" in data
# Verify card is unlinked
db.session.refresh(test_card)
assert test_card.epic_id is None
def test_remove_epic_from_card_card_not_found(
self, client, db_session, auth_headers
):
"""Test unlinking epic from non-existent card"""
response = client.delete(
"/api/cards/99999/epics/1",
headers=auth_headers,
)
assert response.status_code == 404
def test_remove_epic_from_card_wrong_epic(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test unlinking wrong epic from card"""
epic1 = Epic(name="Epic 1", board_id=test_board.id)
epic2 = Epic(name="Epic 2", board_id=test_board.id)
db_session.add(epic1)
db_session.add(epic2)
db_session.commit()
test_card.epic_id = epic1.id
db_session.commit()
# Try to unlink epic2 (but card has epic1)
response = client.delete(
f"/api/cards/{test_card.id}/epics/{epic2.id}",
headers=auth_headers,
)
assert response.status_code == 400
data = response.get_json()
assert "not linked" in data["error"].lower()
def test_remove_epic_from_card_no_epic(
self, client, db_session, auth_headers, test_board, test_card
):
"""Test unlinking epic from card that has no epic"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
# Card has no epic linked
assert test_card.epic_id is None
response = client.delete(
f"/api/cards/{test_card.id}/epics/{epic.id}",
headers=auth_headers,
)
assert response.status_code == 400
data = response.get_json()
assert "not linked" in data["error"].lower()
def test_remove_epic_from_card_unauthorized(
self, client, db_session, test_board, test_card
):
"""Test unlinking epic from card without authentication"""
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(epic)
db_session.commit()
test_card.epic_id = epic.id
db_session.commit()
response = client.delete(f"/api/cards/{test_card.id}/epics/{epic.id}")
assert response.status_code == 401
# ========== Epic Hierarchy Tests ==========
def test_epic_hierarchy(self, client, db_session, auth_headers, test_board):
"""Test creating nested epic hierarchy"""
# Create parent epic
parent_epic = Epic(name="Parent Epic", board_id=test_board.id)
db_session.add(parent_epic)
db_session.commit()
# Create child epic
child_epic = Epic(
name="Child Epic",
board_id=test_board.id,
parent_epic_id=parent_epic.id,
)
db_session.add(child_epic)
db_session.commit()
# Verify hierarchy
response = client.get(
f"/api/epics/{parent_epic.id}",
headers=auth_headers,
)
assert response.status_code == 200
data = response.get_json()
assert data["parent_epic_id"] is None
response = client.get(
f"/api/epics/{child_epic.id}",
headers=auth_headers,
)
assert response.status_code == 200
data = response.get_json()
assert data["parent_epic_id"] == parent_epic.id

View file

@ -0,0 +1,882 @@
"""Integration tests for Wiki API routes"""
import pytest
from app import db
from app.models import Epic, Wiki, wiki_entity_links
@pytest.mark.integration
class TestWikiRoutes:
"""Test Wiki API routes"""
# ========== GET /boards/<board_id>/wikis ==========
def test_get_board_wikis_success(
self, client, db_session, regular_user, auth_headers, test_board
):
"""Test getting all wikis for a board"""
# Create wikis for board
wiki1 = Wiki(
name="Getting Started",
board_id=test_board.id,
slug="getting-started",
content=[
{"type": "paragraph", "children": [{"text": "Getting started content"}]}
],
created_by=regular_user.id,
)
wiki2 = Wiki(
name="API Documentation",
board_id=test_board.id,
slug="api-documentation",
content=[{"type": "paragraph", "children": [{"text": "API docs content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki1)
db_session.add(wiki2)
db_session.commit()
response = client.get(
f"/api/boards/{test_board.id}/wikis", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert len(data) == 2
assert data[0]["name"] == "Getting Started"
assert data[1]["name"] == "API Documentation"
def test_get_board_wikis_empty(self, client, db_session, auth_headers, test_board):
"""Test getting wikis when none exist for board"""
response = client.get(
f"/api/boards/{test_board.id}/wikis", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert len(data) == 0
def test_get_board_wikis_board_not_found(self, client, db_session, auth_headers):
"""Test getting wikis for non-existent board"""
response = client.get("/api/boards/99999/wikis", headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_get_board_wikis_unauthorized(self, client, db_session, test_board):
"""Test getting wikis without authentication"""
response = client.get(f"/api/boards/{test_board.id}/wikis")
assert response.status_code == 401
# ========== POST /boards/<board_id>/wikis ==========
def test_create_wiki_success(
self, client, db_session, regular_user, auth_headers, test_board
):
"""Test creating a new wiki successfully"""
wiki_data = {
"name": "User Guide",
"slug": "user-guide",
"content": [{"type": "paragraph", "children": [{"text": "Welcome..."}]}],
"summary": "A comprehensive user guide",
"category": "Documentation",
"tags": ["getting-started", "tutorial"],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "User Guide"
assert data["slug"] == "user-guide"
assert data["summary"] == "A comprehensive user guide"
assert data["category"] == "Documentation"
assert data["tags"] == ["getting-started", "tutorial"]
assert data["created_by"] == regular_user.id
assert data["updated_by"] == regular_user.id
assert "id" in data
def test_create_wiki_minimal_data(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki with only required fields"""
wiki_data = {
"name": "Simple Wiki",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "Simple Wiki"
assert data["slug"] == "simple-wiki" # Auto-generated
assert data["summary"] is None
assert data["category"] is None
assert data["tags"] is None
def test_create_wiki_auto_generate_slug(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki with auto-generated slug"""
wiki_data = {
"name": "API Reference Guide",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["slug"] == "api-reference-guide"
def test_create_wiki_slug_collision(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test creating wiki with colliding slug"""
# Create first wiki
wiki1 = Wiki(
name="Test Wiki",
board_id=test_board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki1)
db_session.commit()
# Create second wiki with same name (should auto-increment slug)
wiki_data = {
"name": "Test Wiki",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 201
data = response.get_json()
assert data["slug"] == "test-wiki-1"
def test_create_wiki_board_not_found(self, client, db_session, auth_headers):
"""Test creating wiki for non-existent board"""
wiki_data = {
"name": "Test Wiki",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
"/api/boards/99999/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_create_wiki_unauthorized(self, client, db_session, test_board):
"""Test creating wiki without authentication"""
wiki_data = {
"name": "Test Wiki",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
json=wiki_data,
)
assert response.status_code == 401
def test_create_wiki_missing_name(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki without name"""
wiki_data = {"summary": "No name provided"}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 400
def test_create_wiki_name_too_long(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki with name exceeding max length"""
wiki_data = {
"name": "A" * 201,
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 400
def test_create_wiki_invalid_slug(
self, client, db_session, auth_headers, test_board
):
"""Test creating wiki with invalid slug format"""
wiki_data = {
"name": "Test Wiki",
"slug": "invalid slug!",
"content": [{"type": "paragraph", "children": [{"text": "Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/wikis",
headers=auth_headers,
json=wiki_data,
)
assert response.status_code == 400
# ========== GET /wikis/<wiki_id> ==========
def test_get_wiki_success(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test getting a specific wiki with details"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.get(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data["id"] == wiki.id
assert data["name"] == "Test Wiki"
assert data["slug"] == "test-wiki"
assert "linked_cards" in data
assert "linked_epics" in data
def test_get_wiki_with_linked_entities(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test getting wiki with linked cards and epics"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Link card to wiki
db.session.execute(
wiki_entity_links.insert().values(
wiki_id=wiki.id, entity_type="card", entity_id=test_card.id
)
)
db_session.commit()
response = client.get(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "linked_cards" in data
assert "linked_epics" in data
assert len(data["linked_cards"]) == 1
assert data["linked_cards"][0]["id"] == test_card.id
def test_get_wiki_not_found(self, client, db_session, auth_headers):
"""Test getting non-existent wiki"""
response = client.get("/api/wikis/99999", headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert "not found" in data["error"].lower()
def test_get_wiki_unauthorized(self, client, db_session, test_board, regular_user):
"""Test getting wiki without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.get(f"/api/wikis/{wiki.id}")
assert response.status_code == 401
# ========== PUT /wikis/<wiki_id> ==========
def test_update_wiki_success(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating a wiki successfully"""
wiki = Wiki(
name="Original Name",
board_id=test_board.id,
slug="original-slug",
content=[{"type": "paragraph", "children": [{"text": "Original content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
update_data = {
"name": "Updated Name",
"slug": "updated-slug",
"summary": "Updated summary",
"category": "Documentation",
"tags": ["updated", "tag"],
}
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json=update_data,
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Name"
assert data["slug"] == "updated-slug"
assert data["summary"] == "Updated summary"
assert data["category"] == "Documentation"
assert data["tags"] == ["updated", "tag"]
def test_update_wiki_partial(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating wiki with partial data"""
wiki = Wiki(
name="Original Name",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Original content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
update_data = {"name": "Updated Name"}
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json=update_data,
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Name"
# Other fields should remain unchanged
def test_update_wiki_content(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating wiki content"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Original content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
new_content = [{"type": "heading", "children": [{"text": "Updated Content"}]}]
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json={"content": new_content},
)
assert response.status_code == 200
data = response.get_json()
assert data["content"] == new_content
def test_update_wiki_not_found(self, client, db_session, auth_headers):
"""Test updating non-existent wiki"""
response = client.put(
"/api/wikis/99999",
headers=auth_headers,
json={"name": "Updated"},
)
assert response.status_code == 404
def test_update_wiki_unauthorized(
self, client, db_session, test_board, regular_user
):
"""Test updating wiki without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.put(f"/api/wikis/{wiki.id}", json={"name": "Updated"})
assert response.status_code == 401
def test_update_wiki_invalid_name(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating wiki with invalid name"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json={"name": ""},
)
assert response.status_code == 400
def test_update_wiki_invalid_slug(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test updating wiki with invalid slug"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.put(
f"/api/wikis/{wiki.id}",
headers=auth_headers,
json={"slug": "invalid slug!"},
)
assert response.status_code == 400
# ========== DELETE /wikis/<wiki_id> ==========
def test_delete_wiki_success(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test deleting a wiki successfully"""
wiki = Wiki(
name="To Delete",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
wiki_id = wiki.id
response = client.delete(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "message" in data
# Verify wiki is deleted
deleted_wiki = db.session.get(Wiki, wiki_id)
assert deleted_wiki is None
def test_delete_wiki_with_links(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test deleting wiki removes entity links"""
wiki = Wiki(
name="Wiki with Links",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Link card to wiki
db.session.execute(
wiki_entity_links.insert().values(
wiki_id=wiki.id, entity_type="card", entity_id=test_card.id
)
)
db_session.commit()
response = client.delete(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
# Verify links are deleted (cascade)
from sqlalchemy import select
links = db.session.scalars(
select(wiki_entity_links).where(wiki_entity_links.c.wiki_id == wiki.id)
).all()
assert len(links) == 0
def test_delete_wiki_not_found(self, client, db_session, auth_headers):
"""Test deleting non-existent wiki"""
response = client.delete("/api/wikis/99999", headers=auth_headers)
assert response.status_code == 404
def test_delete_wiki_unauthorized(
self, client, db_session, test_board, regular_user
):
"""Test deleting wiki without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.delete(f"/api/wikis/{wiki.id}")
assert response.status_code == 401
# ========== POST /wikis/<wiki_id>/links ==========
def test_create_wiki_link_card(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test linking wiki to card successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": test_card.id},
)
assert response.status_code == 201
data = response.get_json()
assert "message" in data
# Verify link is created
from sqlalchemy import select
link = db.session.scalars(
select(wiki_entity_links).where(
wiki_entity_links.c.wiki_id == wiki.id,
wiki_entity_links.c.entity_type == "card",
wiki_entity_links.c.entity_id == test_card.id,
)
).first()
assert link is not None
def test_create_wiki_link_epic(
self,
client,
db_session,
auth_headers,
test_board,
regular_user,
test_list,
):
"""Test linking wiki to epic successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
epic = Epic(name="Test Epic", board_id=test_board.id)
db_session.add(wiki)
db_session.add(epic)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "epic", "entity_id": epic.id},
)
assert response.status_code == 201
# Verify link is created
from sqlalchemy import select
link = db.session.scalars(
select(wiki_entity_links).where(
wiki_entity_links.c.wiki_id == wiki.id,
wiki_entity_links.c.entity_type == "epic",
wiki_entity_links.c.entity_id == epic.id,
)
).first()
assert link is not None
def test_create_wiki_link_list(
self,
client,
db_session,
auth_headers,
test_board,
regular_user,
test_list,
):
"""Test linking wiki to list successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "list", "entity_id": test_list.id},
)
assert response.status_code == 201
def test_create_wiki_link_board(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test linking wiki to board successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "board", "entity_id": test_board.id},
)
assert response.status_code == 201
def test_create_wiki_link_already_exists(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test creating duplicate link"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Create first link
db.session.execute(
wiki_entity_links.insert().values(
wiki_id=wiki.id, entity_type="card", entity_id=test_card.id
)
)
db_session.commit()
# Try to create duplicate link
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": test_card.id},
)
assert response.status_code == 400
data = response.get_json()
assert "already exists" in data["error"].lower()
def test_create_wiki_link_wiki_not_found(self, client, db_session, auth_headers):
"""Test linking non-existent wiki"""
response = client.post(
"/api/wikis/99999/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": 1},
)
assert response.status_code == 404
def test_create_wiki_link_entity_not_found(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test linking to non-existent entity"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": 99999},
)
assert response.status_code == 404
def test_create_wiki_link_unauthorized(
self, client, db_session, test_board, regular_user
):
"""Test creating link without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.post(
f"/api/wikis/{wiki.id}/links",
json={"entity_type": "card", "entity_id": 1},
)
assert response.status_code == 401
# ========== DELETE /wikis/<wiki_id>/links ==========
def test_delete_wiki_link_success(
self, client, db_session, auth_headers, test_board, regular_user, test_card
):
"""Test deleting a wiki link successfully"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Create link
db.session.execute(
wiki_entity_links.insert().values(
wiki_id=wiki.id, entity_type="card", entity_id=test_card.id
)
)
db_session.commit()
response = client.delete(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": test_card.id},
)
assert response.status_code == 200
data = response.get_json()
assert "message" in data
# Verify link is deleted
from sqlalchemy import select
link = db.session.scalars(
select(wiki_entity_links).where(
wiki_entity_links.c.wiki_id == wiki.id,
wiki_entity_links.c.entity_type == "card",
wiki_entity_links.c.entity_id == test_card.id,
)
).first()
assert link is None
def test_delete_wiki_link_wiki_not_found(self, client, db_session, auth_headers):
"""Test deleting link for non-existent wiki"""
response = client.delete(
"/api/wikis/99999/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": 1},
)
assert response.status_code == 404
def test_delete_wiki_link_not_found(
self, client, db_session, auth_headers, test_board, regular_user
):
"""Test deleting non-existent link"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.delete(
f"/api/wikis/{wiki.id}/links",
headers=auth_headers,
json={"entity_type": "card", "entity_id": 99999},
)
assert response.status_code == 404
def test_delete_wiki_link_unauthorized(
self, client, db_session, test_board, regular_user
):
"""Test deleting link without authentication"""
wiki = Wiki(
name="Test Wiki",
board_id=test_board.id,
content=[{"type": "paragraph", "children": [{"text": "Test content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
response = client.delete(
f"/api/wikis/{wiki.id}/links",
json={"entity_type": "card", "entity_id": 1},
)
assert response.status_code == 401

View file

@ -1,9 +1,8 @@
"""Test models""" """Test models"""
from decimal import Decimal
import pytest import pytest
from app.models import Order, OrderItem, Product, User from app.models import User
class TestUserModel: class TestUserModel:
@ -69,132 +68,3 @@ class TestUserModel:
db_session.commit() db_session.commit()
assert repr(user) == "<User testuser3>" assert repr(user) == "<User testuser3>"
class TestProductModel:
"""Test Product model"""
@pytest.mark.unit
def test_product_creation(self, db_session):
"""Test creating a product"""
product = Product(
name="Test Product",
description="A test product",
price=Decimal("99.99"),
stock=10,
image_url="https://example.com/product.jpg",
)
db_session.add(product)
db_session.commit()
assert product.id is not None
assert product.name == "Test Product"
assert product.price == Decimal("99.99")
assert product.stock == 10
assert product.is_active is True
@pytest.mark.unit
def test_product_to_dict(self, db_session):
"""Test product serialization to dictionary"""
product = Product(
name="Test Product",
description="A test product",
price=Decimal("99.99"),
stock=10,
)
db_session.add(product)
db_session.commit()
product_dict = product.to_dict()
assert product_dict["name"] == "Test Product"
assert product_dict["price"] == 99.99
assert isinstance(product_dict["created_at"], str)
assert isinstance(product_dict["updated_at"], str)
@pytest.mark.unit
def test_product_defaults(self, db_session):
"""Test product default values"""
product = Product(name="Test Product", price=Decimal("9.99"))
db_session.add(product)
db_session.commit()
assert product.stock == 0
assert product.is_active is True
assert product.description is None
assert product.image_url is None
@pytest.mark.unit
def test_product_repr(self, db_session):
"""Test product string representation"""
product = Product(name="Test Product", price=Decimal("9.99"))
db_session.add(product)
db_session.commit()
assert repr(product) == "<Product Test Product>"
class TestOrderModel:
"""Test Order model"""
@pytest.mark.unit
def test_order_creation(self, db_session, regular_user):
"""Test creating an order"""
order = Order(
user_id=regular_user.id,
total_amount=Decimal("199.99"),
shipping_address="123 Test St",
)
db_session.add(order)
db_session.commit()
assert order.id is not None
assert order.user_id == regular_user.id
assert order.total_amount == Decimal("199.99")
@pytest.mark.unit
def test_order_to_dict(self, db_session, regular_user):
"""Test order serialization to dictionary"""
order = Order(
user_id=regular_user.id,
total_amount=Decimal("199.99"),
shipping_address="123 Test St",
)
db_session.add(order)
db_session.commit()
order_dict = order.to_dict()
assert order_dict["user_id"] == regular_user.id
assert order_dict["total_amount"] == 199.99
assert isinstance(order_dict["created_at"], str)
class TestOrderItemModel:
"""Test OrderItem model"""
@pytest.mark.unit
def test_order_item_creation(self, db_session, order, product):
"""Test creating an order item"""
order_item = OrderItem(
order_id=order.id, product_id=product.id, quantity=2, price=product.price
)
db_session.add(order_item)
db_session.commit()
assert order_item.id is not None
assert order_item.order_id == order.id
assert order_item.product_id == product.id
assert order_item.quantity == 2
@pytest.mark.unit
def test_order_item_to_dict(self, db_session, order, product):
"""Test order item serialization to dictionary"""
order_item = OrderItem(
order_id=order.id, product_id=product.id, quantity=2, price=product.price
)
db_session.add(order_item)
db_session.commit()
item_dict = order_item.to_dict()
assert item_dict["order_id"] == order.id
assert item_dict["product_id"] == product.id
assert item_dict["quantity"] == 2

View file

@ -112,234 +112,3 @@ class TestAuthRoutes:
"""Test getting current user without authentication""" """Test getting current user without authentication"""
response = client.get("/api/users/me") response = client.get("/api/users/me")
assert response.status_code == 401 assert response.status_code == 401
class TestProductRoutes:
"""Test product routes"""
@pytest.mark.product
def test_get_products(self, app, client, products):
"""Test getting all products"""
from app.models import Product
before_count = Product.query.count()
response = client.get("/api/products")
assert response.status_code == 200
data = response.get_json()
assert len(data) == before_count
@pytest.mark.product
def test_get_products_empty(self, client):
"""Test getting products when none exist"""
from app.models import Product
before_count = Product.query.count()
response = client.get("/api/products")
assert response.status_code == 200
data = response.get_json()
assert len(data) == before_count
@pytest.mark.product
def test_get_single_product(self, client, product):
"""Test getting a single product"""
response = client.get(f"/api/products/{product.id}")
assert response.status_code == 200
data = response.get_json()
assert data["id"] == product.id
assert data["name"] == product.name
@pytest.mark.product
def test_get_product_not_found(self, client):
"""Test getting non-existent product"""
response = client.get("/api/products/999")
assert response.status_code == 404
@pytest.mark.product
def test_create_product_admin(self, client, admin_headers):
"""Test creating product as admin"""
response = client.post(
"/api/products",
headers=admin_headers,
json={
"name": "New Product",
"description": "A new product",
"price": 29.99,
"stock": 10,
},
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "New Product"
assert data["price"] == 29.99
@pytest.mark.product
def test_create_product_regular_user(self, client, auth_headers):
"""Test creating product as regular user (should fail)"""
response = client.post(
"/api/products",
headers=auth_headers,
json={"name": "New Product", "price": 29.99},
)
assert response.status_code == 403
data = response.get_json()
assert "admin" in data["error"].lower()
@pytest.mark.product
def test_create_product_unauthorized(self, client):
"""Test creating product without authentication"""
response = client.post(
"/api/products", json={"name": "New Product", "price": 29.99}
)
assert response.status_code == 401
@pytest.mark.product
def test_create_product_validation_error(self, client, admin_headers):
"""Test creating product with invalid data"""
response = client.post(
"/api/products",
headers=admin_headers,
json={"name": "New Product", "price": -10.99},
)
assert response.status_code == 400
data = response.get_json()
assert "Validation error" in data["error"]
@pytest.mark.product
def test_create_product_missing_required_fields(self, client, admin_headers):
"""Test creating product with missing required fields"""
response = client.post(
"/api/products",
headers=admin_headers,
json={"description": "Missing name and price"},
)
assert response.status_code == 400
data = response.get_json()
assert "Validation error" in data["error"]
@pytest.mark.product
def test_create_product_minimal_data(self, client, admin_headers):
"""Test creating product with minimal valid data"""
response = client.post(
"/api/products",
headers=admin_headers,
json={"name": "Minimal Product", "price": 19.99},
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "Minimal Product"
assert data["stock"] == 0 # Default value
@pytest.mark.product
def test_update_product_admin(self, client, admin_headers, product):
"""Test updating product as admin"""
response = client.put(
f"/api/products/{product.id}",
headers=admin_headers,
json={"name": "Updated Product", "price": 39.99},
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Product"
assert data["price"] == 39.99
@pytest.mark.product
def test_delete_product_admin(self, client, admin_headers, product):
"""Test deleting product as admin"""
response = client.delete(f"/api/products/{product.id}", headers=admin_headers)
assert response.status_code == 200
# Verify product is deleted
response = client.get(f"/api/products/{product.id}")
assert response.status_code == 404
class TestOrderRoutes:
"""Test order routes"""
@pytest.mark.order
def test_get_orders(self, client, auth_headers, order):
"""Test getting orders for current user"""
response = client.get("/api/orders", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert len(data) >= 1
@pytest.mark.order
def test_get_orders_unauthorized(self, client):
"""Test getting orders without authentication"""
response = client.get("/api/orders")
assert response.status_code == 401
@pytest.mark.order
def test_create_order(self, client, auth_headers, products):
"""Test creating an order"""
response = client.post(
"/api/orders",
headers=auth_headers,
json={
"items": [
{"product_id": products[0].id, "quantity": 2},
{"product_id": products[1].id, "quantity": 1},
],
"shipping_address": "123 Test St",
},
)
assert response.status_code == 201
data = response.get_json()
assert "id" in data
assert len(data["items"]) == 2
@pytest.mark.order
def test_create_order_insufficient_stock(
self, client, auth_headers, db_session, products
):
"""Test creating order with insufficient stock"""
# Set stock to 0
products[0].stock = 0
db_session.commit()
response = client.post(
"/api/orders",
headers=auth_headers,
json={"items": [{"product_id": products[0].id, "quantity": 2}]},
)
assert response.status_code == 400
data = response.get_json()
assert "insufficient" in data["error"].lower()
@pytest.mark.order
def test_get_single_order(self, client, auth_headers, order):
"""Test getting a single order"""
response = client.get(f"/api/orders/{order.id}", headers=auth_headers)
print("test_get_single_order", response.get_json())
assert response.status_code == 200
data = response.get_json()
assert data["id"] == order.id
@pytest.mark.order
def test_get_other_users_order(self, client, admin_headers, regular_user, products):
"""Test admin accessing another user's order"""
# Create an order for regular_user
client.post(
"/api/auth/login",
json={"email": regular_user.email, "password": "password123"},
)
# Admin should be able to access any order
# This test assumes order exists, adjust as needed
pass

View file

@ -1,249 +0,0 @@
"""Test Pydantic schemas"""
from decimal import Decimal
import pytest
from pydantic import ValidationError
from app.schemas import ProductCreateRequest, ProductResponse
class TestProductCreateRequestSchema:
"""Test ProductCreateRequest schema"""
@pytest.mark.unit
def test_valid_product_request(self):
"""Test valid product creation request"""
data = {
"name": "Handcrafted Wooden Bowl",
"description": "A beautiful handcrafted bowl",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/bowl.jpg",
}
product = ProductCreateRequest(**data)
assert product.name == data["name"]
assert product.description == data["description"]
assert product.price == Decimal("45.99")
assert product.stock == 10
assert product.image_url == data["image_url"]
@pytest.mark.unit
def test_minimal_valid_request(self):
"""Test minimal valid request (only required fields)"""
data = {"name": "Simple Product", "price": 19.99}
product = ProductCreateRequest(**data)
assert product.name == "Simple Product"
assert product.price == Decimal("19.99")
assert product.stock == 0
assert product.description is None
assert product.image_url is None
@pytest.mark.unit
def test_missing_name(self):
"""Test request with missing name"""
data = {"price": 19.99}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("name",) for error in errors)
@pytest.mark.unit
def test_missing_price(self):
"""Test request with missing price"""
data = {"name": "Test Product"}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("price",) for error in errors)
@pytest.mark.unit
def test_invalid_price_negative(self):
"""Test request with negative price"""
data = {"name": "Test Product", "price": -10.99}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than" for error in errors)
@pytest.mark.unit
def test_invalid_price_zero(self):
"""Test request with zero price"""
data = {"name": "Test Product", "price": 0.0}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than" for error in errors)
@pytest.mark.unit
def test_invalid_price_too_many_decimals(self):
"""Test request with too many decimal places"""
data = {"name": "Test Product", "price": 10.999}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any("decimal places" in str(error).lower() for error in errors)
@pytest.mark.unit
def test_invalid_stock_negative(self):
"""Test request with negative stock"""
data = {"name": "Test Product", "price": 19.99, "stock": -5}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["type"] == "greater_than_equal" for error in errors)
@pytest.mark.unit
def test_name_too_long(self):
"""Test request with name exceeding max length"""
data = {"name": "A" * 201, "price": 19.99} # Exceeds 200 character limit
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("name",) for error in errors)
@pytest.mark.unit
def test_image_url_too_long(self):
"""Test request with image_url exceeding max length"""
data = {
"name": "Test Product",
"price": 19.99,
"image_url": "A" * 501, # Exceeds 500 character limit
}
with pytest.raises(ValidationError) as exc_info:
ProductCreateRequest(**data)
errors = exc_info.value.errors()
assert any(error["loc"] == ("image_url",) for error in errors)
@pytest.mark.unit
def test_price_string_conversion(self):
"""Test price string to Decimal conversion"""
data = {"name": "Test Product", "price": "29.99"}
product = ProductCreateRequest(**data)
assert product.price == Decimal("29.99")
@pytest.mark.unit
def test_stock_string_conversion(self):
"""Test stock string to int conversion"""
data = {"name": "Test Product", "price": 19.99, "stock": "10"}
product = ProductCreateRequest(**data)
assert product.stock == 10
assert isinstance(product.stock, int)
class TestProductResponseSchema:
"""Test ProductResponse schema"""
@pytest.mark.unit
def test_valid_product_response(self):
"""Test valid product response"""
data = {
"id": 1,
"name": "Test Product",
"description": "A test product",
"price": 45.99,
"stock": 10,
"image_url": "https://example.com/product.jpg",
"is_active": True,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-01-15T10:30:00",
}
product = ProductResponse(**data)
assert product.id == 1
assert product.name == "Test Product"
assert product.price == 45.99
assert product.stock == 10
assert product.is_active is True
@pytest.mark.unit
def test_product_response_with_none_fields(self):
"""Test product response with optional None fields"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 0,
"is_active": True,
}
product = ProductResponse(**data)
assert product.description is None
assert product.image_url is None
assert product.created_at is None
assert product.updated_at is None
@pytest.mark.unit
def test_model_validate_from_sqlalchemy(self, db_session):
"""Test validating SQLAlchemy model to Pydantic schema"""
from app.models import Product
db_product = Product(
name="Test Product",
description="A test product",
price=Decimal("45.99"),
stock=10,
)
db_session.add(db_product)
db_session.commit()
# Validate using model_validate (for SQLAlchemy models)
response = ProductResponse.model_validate(db_product)
assert response.name == "Test Product"
assert response.price == 45.99
assert response.stock == 10
@pytest.mark.unit
def test_model_dump(self):
"""Test model_dump method"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 5,
"is_active": True,
}
product = ProductResponse(**data)
dumped = product.model_dump()
assert isinstance(dumped, dict)
assert dumped["id"] == 1
assert dumped["name"] == "Test Product"
assert dumped["price"] == 19.99
@pytest.mark.unit
def test_model_dump_json(self):
"""Test model_dump_json method"""
data = {
"id": 1,
"name": "Test Product",
"price": 19.99,
"stock": 5,
"is_active": True,
}
product = ProductResponse(**data)
json_str = product.model_dump_json()
assert isinstance(json_str, str)
assert "Test Product" in json_str

View file

@ -0,0 +1,338 @@
# Epic & Wiki Feature Implementation Summary
## Overview
This document summarizes the implementation of the Epic and Wiki features for the Kanban application.
## What Was Implemented
### Backend (Flask)
#### 1. Database Models
**Epic Model** (`backend/app/models/epic.py`)
- Rich text content support (JSON for Slate.js)
- Hierarchical structure (parent_epic_id for nesting)
- Color coding
- Position ordering
- Board-level scoping
- Soft delete support (closed field)
- Depth limit for hierarchy control
**Wiki Model** (`backend/app/models/wiki.py`)
- Rich text content (JSON for Slate.js)
- URL-friendly slugs
- Summary field
- Categories for organization
- Tags support
- Created by / Updated by tracking
- Board-level scoping
**Card Model Update** (`backend/app/models/card.py`)
- Added `epic_id` foreign key to link cards to epics
**Association Table** (`wiki_entity_links`)
- Many-to-many relationship between Wikis and entity types
- Supports linking Wikis to Cards, Epics, and future entity types
#### 2. Database Migration
- Created migration file: `6fc439155ced_add_epic_and_wiki_models.py`
- Adds all new tables and relationships
### Frontend (React/TypeScript)
#### 1. TypeScript Types (`frontend/src/types/epic.ts`)
```typescript
- Epic interface
- Wiki interface
- CreateEpicRequest / UpdateEpicRequest
- CreateWikiRequest / UpdateWikiRequest
- WikiEntityLink interface
```
#### 2. Components
**RichTextEditor** (`frontend/src/components/RichTextEditor.tsx`)
- Slate.js-based rich text editor
- Editable with proper styling
- Dark mode support
- Placeholder support
- Read-only mode option
**RichTextContent** (`frontend/src/components/RichTextContent.tsx`)
- Read-only renderer for Slate.js content
- Supports paragraphs, lists, blockquotes
- Text formatting (bold, italic, underline, code)
- Dark mode styling
#### 3. Custom Hook
**useEpics** (`frontend/src/hooks/useEpics.ts`)
- Fetch all epics for a board
- Create new epic
- Update existing epic
- Delete epic
- Integrated with global loader and toast notifications
- Error handling with user-friendly messages
#### 4. API Integration
Updated `useApi` hook with epic methods:
```typescript
- getEpics(boardId)
- createEpic(boardId, epicData)
- getEpic(epicId)
- updateEpic(epicId, epicData)
- deleteEpic(epicId)
- addEpicToCard(cardId, epicId)
- removeEpicFromCard(cardId, epicId)
```
## Key Design Decisions
### 1. Epic Hierarchy
- **Decision**: Self-referential foreign key (`parent_epic_id`)
- **Rationale**: Allows flexible nesting of epics with arbitrary depth
- **Feature**: `depth_limit` field to control maximum nesting depth
### 2. Rich Text Storage
- **Decision**: Store as JSON (compatible with Slate.js)
- **Rationale**:
- Slate.js natively uses JSON format
- No serialization/deserialization overhead
- Easy to query and modify content structure
### 3. Wiki vs Document Naming
- **Decision**: Use "Wiki" instead of "Document"
- **Rationale**: Avoids confusion with file attachments
- **Meaning**: Wiki implies knowledge base / documentation repository
### 4. Entity Linking Strategy
- **Decision**: Polymorphic association table (`wiki_entity_links`)
- **Rationale**:
- Single table handles all entity types
- Easy to add new entity types in future
- Avoids circular imports and complex schema changes
### 5. Epic-Card Relationship
- **Decision**: One-way reference (Card → Epic)
- **Rationale**:
- Simpler than many-to-many (epics contain cards)
- Cards can belong to one epic at a time
- Consistent with Jira's parent/child pattern
## Architecture
### Backend Data Flow
```
Board (1) ───────┬─────── (1) Epic
│ │
│ │─── (0..*) Card (via epic_id)
└───────────────┬─────── (0..*) Wiki
└─── wiki_entity_links ───┬── Card
├── Epic
└── (future entities)
```
### Frontend Component Structure
```
BoardEpics (page)
├── EpicList
│ └── EpicCard
└── CreateEpicModal
├── EpicForm
│ ├── EpicNameInput
│ ├── EpicDescriptionInput
│ └── RichTextEditor (content)
└── ColorPicker
```
## Next Steps
### Immediate Actions Required
1. **Run Database Migration**
```bash
cd backend
flask db upgrade
```
2. **Create Backend Routes** (`backend/app/routes/kanban/epics.py`)
- CRUD operations for Epics
- Epic-Card linking endpoints
- Wiki CRUD operations
- Wiki-Entity linking endpoints
3. **Create Backend Schemas** (`backend/app/schemas/epic.py`, `wiki.py`)
- Marshmallow schemas for serialization
- Input validation
4. **Update BoardEpics Page** (`frontend/src/pages/BoardEpics.tsx`)
- Implement epic list view
- Add create epic modal
- Add epic detail view
- Include RichTextEditor for epic content
5. **Update Card Detail Page** (`frontend/src/pages/CardDetail.tsx`)
- Add epic selector dropdown
- Display linked epic information
- Add wiki links
### Future Enhancements
1. **Wiki Features**
- Wiki page with sidebar navigation
- Markdown export/import
- Wiki search functionality
- Version history
2. **Epic Features**
- Epic progress tracking (cards completed vs total)
- Epic metrics dashboard
- Epic drag-and-drop reordering
- Epic timeline view
3. **UI/UX Improvements**
- Epic color picker with presets
- Epic hierarchy tree view
- Inline card epic assignment
- Epic templates
4. **Analytics**
- Epic completion rate
- Time spent on epic
- Epic size distribution
- Wiki usage statistics
## Database Schema
### Epic Table
```sql
CREATE TABLE epics (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
content JSONB,
color VARCHAR(7),
closed BOOLEAN DEFAULT FALSE,
pos FLOAT,
depth_limit INTEGER,
board_id INTEGER REFERENCES boards(id),
parent_epic_id INTEGER REFERENCES epics(id),
date_last_activity TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Wiki Table
```sql
CREATE TABLE wikis (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE,
content JSONB NOT NULL,
summary TEXT,
category VARCHAR(100),
board_id INTEGER REFERENCES boards(id),
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Wiki Entity Links Table
```sql
CREATE TABLE wiki_entity_links (
wiki_id INTEGER REFERENCES wikis(id) ON DELETE CASCADE,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
linked_by INTEGER REFERENCES users(id),
PRIMARY KEY (wiki_id, entity_type, entity_id)
);
```
## API Endpoints (To Be Implemented)
### Epic Endpoints
```
GET /api/boards/<board_id>/epics
POST /api/boards/<board_id>/epics
GET /api/epics/<epic_id>
PUT /api/epics/<epic_id>
DELETE /api/epics/<epic_id>
```
### Epic-Card Linking
```
POST /api/cards/<card_id>/epics
DELETE /api/cards/<card_id>/epics/<epic_id>
```
### Wiki Endpoints
```
GET /api/boards/<board_id>/wikis
POST /api/boards/<board_id>/wikis
GET /api/wikis/<wiki_id>
PUT /api/wikis/<wiki_id>
DELETE /api/wikis/<wiki_id>
GET /api/wikis/<wiki_id>/content
```
### Wiki-Entity Linking
```
POST /api/wikis/<wiki_id>/links
DELETE /api/wikis/<wiki_id>/links/<entity_type>/<entity_id>
```
## Testing Considerations
### Unit Tests
- Epic model creation and relationships
- Wiki model creation and relationships
- Epic hierarchy validation
- Wiki slug generation
- Rich text content serialization
### Integration Tests
- Epic CRUD operations
- Wiki CRUD operations
- Epic-Card linking
- Wiki-Entity linking
- Epic hierarchy operations
### Frontend Tests
- RichTextEditor component
- RichTextContent component
- useEpics hook
- Epic list rendering
- Epic creation form
## Notes
- **Slate.js Content**: Content is stored as raw Slate.js JSON format, which includes element types, text nodes, and formatting information
- **Hierarchical Epics**: While supported, UI for nesting epics is not yet implemented
- **Wiki Slugs**: Slugs should be auto-generated from wiki names and checked for uniqueness
- **Color Codes**: Epic colors should be valid hex codes (e.g., "#ef4444")
- **Position**: Epic positions use float values for flexible reordering (similar to Trello)
## Related Files
### Backend
- `backend/app/models/epic.py` - Epic model
- `backend/app/models/wiki.py` - Wiki model
- `backend/app/models/card.py` - Updated Card model
- `backend/app/models/__init__.py` - Model imports
- `backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py` - Migration
### Frontend
- `frontend/src/types/epic.ts` - TypeScript interfaces
- `frontend/src/components/RichTextEditor.tsx` - Editor component
- `frontend/src/components/RichTextContent.tsx` - Content renderer
- `frontend/src/hooks/useEpics.ts` - Epic custom hook
- `frontend/src/hooks/useApi.ts` - Updated API methods

View file

@ -0,0 +1,238 @@
# Epic and Wiki Models - Implementation Summary
## Overview
This document summarizes the implementation of Epic and Wiki models for the Kanban application.
## What Was Implemented
### 1. Epic Model (`backend/app/models/epic.py`)
- Tracks large features across multiple cards
- Hierarchical structure (parent-child epics)
- Rich text content support (Slate.js JSON)
- Color-coded badges for visual identification
- Metrics tracking (card count)
**Key Fields:**
- `id`, `name`, `description`, `content` (JSONB)
- `color` (hex code for epic badge)
- `closed`, `pos`, `depth_limit` (default 5)
- `board_id`, `parent_epic_id`
- `date_last_activity`, `created_at`, `updated_at`
- `metrics` (JSONB - stores card_count)
**Relationships:**
- Board: One-to-many (Board has many Epics)
- Cards: One-to-many (Epic has many Cards)
- Parent Epic: Self-referential (hierarchical)
- File Attachments: Polymorphic (like Cards)
### 2. Wiki Model (`backend/app/models/wiki.py`)
- Reusable rich text content within a board
- Board-scoped (not global across all boards)
- Polymorphic links to entities (Card, Epic, etc.)
- Categorization and tagging support
**Key Fields:**
- `id`, `name`, `slug` (URL-friendly)
- `content` (JSONB - rich text)
- `summary`, `category`, `tags` (JSONB)
- `board_id`, `created_by`, `updated_by`
- `created_at`, `updated_at`
**Relationships:**
- Board: One-to-many (Board has many Wikis)
- Entities: Many-to-many polymorphic (via wiki_entity_links)
### 3. Card Model Updates (`backend/app/models/card.py`)
- Added `epic_id` foreign key (nullable)
- Updated `to_dict()` to include `epic_id`
- One-to-one relationship: Card belongs to one Epic
### 4. Association Table (`wiki_entity_links`)
- Polymorphic many-to-many table
- Links wikis to any entity type
- Fields: `wiki_id`, `entity_type`, `entity_id`, `created_at`, `linked_by`
## Database Schema
### New Tables
1. **epics** - Epic records
2. **wikis** - Wiki content records
3. **wiki_entity_links** - Wiki-to-entity associations
### Modified Tables
1. **cards** - Added `epic_id` foreign key
### Relationships Diagram
```
Board (1) ----< (N) Epic
Epic (1) ----< (N) Card (each card belongs to one epic)
Epic (1) ----< (N) Epic (parent-child hierarchy)
Board (1) ----< (N) Wiki
Wiki (M) ----> (M) Entity (polymorphic: Card, Epic)
```
## Database Migration
**File:** `backend/migrations/versions/add_epic_and_wiki_models.py`
**Creates:**
- `epics` table with indexes on `board_id`, `closed`, `name`
- `wikis` table with indexes on `board_id`, `name`, `slug`
- `wiki_entity_links` table with composite primary key
- `epic_id` column in `cards` table with index and foreign key
**To apply migration:**
```bash
cd backend
flask db upgrade
```
**To rollback:**
```bash
flask db downgrade
```
## Model Exports
Updated `backend/app/models/__init__.py` to include:
- `from app.models.epic import Epic`
- `from app.models.wiki import Wiki`
Added to `__all__` list: `"Epic"`, `"Wiki"`
## Design Decisions
### Why "Wiki" instead of "Document"?
- Avoids confusion with file attachments
- Emphasizes reusable knowledge content
- Better semantic meaning for rich text resources
### One-to-Many Epic-Card Relationship
- Simpler, clearer ownership
- Each card belongs to one epic
- Easier to query and display
### Board-Scoped Wikis
- Wikis belong to a specific board
- Not global across all boards
- Better organization and access control
### Epic Hierarchy Depth
- Default depth limit: 5 levels
- Configurable per epic
- Backend should enforce when creating child epics
### Simplified Metrics
- Currently only tracks `card_count`
- Stored in JSONB field: `{"card_count": 10}`
- Easy to extend with more metrics later
## Next Steps
### Backend Implementation
1. ✅ Create models - **DONE**
2. ✅ Create database migration - **DONE**
3. ⏭️ Create schemas for serialization
4. ⏭️ Create API routes (CRUD operations)
5. ⏭️ Create services for business logic
6. ⏭️ Add validation for epic depth limit
7. ⏭️ Update epic metrics when cards change
8. ⏭️ Write tests for models and routes
### Frontend Implementation
1. ⏭️ Update TypeScript types
2. ⏭️ Create Epic page/component
3. ⏭️ Create Wiki page/component
4. ⏭️ Add epic dropdown to card detail
5. ⏭️ Create epic list on board detail
6. ⏭️ Implement rich text editor (Slate.js)
7. ⏭️ Add wiki linking UI
### API Endpoints (Future)
#### Epic Endpoints
```
GET /api/boards/{board_id}/epics # List all epics for board
POST /api/boards/{board_id}/epics # Create epic
GET /api/epics/{epic_id} # Get epic details with cards
PUT /api/epics/{epic_id} # Update epic
DELETE /api/epics/{epic_id} # Delete epic
POST /api/epics/{epic_id}/cards # Create card directly in epic
GET /api/epics/{epic_id}/tree # Get epic hierarchy tree
```
#### Wiki Endpoints
```
GET /api/boards/{board_id}/wikis # List all wikis for board
POST /api/boards/{board_id}/wikis # Create wiki
GET /api/wikis/{wiki_id} # Get wiki details
PUT /api/wikis/{wiki_id} # Update wiki
DELETE /api/wikis/{wiki_id} # Delete wiki
POST /api/wikis/{wiki_id}/links # Link wiki to entity
DELETE /api/wikis/{wiki_id}/links/{link_id} # Unlink from entity
GET /api/{entity_type}/{entity_id}/wikis # Get wikis for entity
```
#### Card Endpoints (Updated)
```
PUT /api/cards/{card_id}/epic # Link card to epic (or null to unlink)
GET /api/cards/{card_id}/epic # Get card's epic
```
## Files Created/Modified
### Created
- `backend/app/models/epic.py`
- `backend/app/models/wiki.py`
- `backend/migrations/versions/add_epic_and_wiki_models.py`
### Modified
- `backend/app/models/card.py` (added epic_id)
- `backend/app/models/__init__.py` (added Epic, Wiki imports)
## Testing the Implementation
1. **Apply migration:**
```bash
cd backend
source venv/bin/activate
flask db upgrade
```
2. **Verify tables created:**
```bash
flask dbcurrent
# Should show: add_epic_and_wiki_models
```
3. **Test in Python shell:**
```python
from app import create_app, db
from app.models import Epic, Wiki, Card, Board
app = create_app()
with app.app_context():
# Create an epic
epic = Epic(name="My Epic", board_id=1, color="#3b82f6")
db.session.add(epic)
db.session.commit()
# Create a wiki
wiki = Wiki(name="Security Guide", board_id=1, content={})
db.session.add(wiki)
db.session.commit()
print(f"Epic created: {epic.to_dict()}")
print(f"Wiki created: {wiki.to_dict()}")
```
## Notes
- All models follow the existing project patterns
- Uses `db` from `app` module (not `flask_sqlalchemy` directly)
- Proper foreign key constraints with CASCADE/SET NULL
- Timestamps use UTC timezone
- JSONB fields for flexible data storage
- Indexed for optimal query performance

View file

@ -372,13 +372,13 @@ useEffect(() => {
``` ```
### Icons ### Icons
- **ALWAYS** use inline SVG icons - **ALWAYS** create icon components in `frontend/src/components/icons/`
- **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome - **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome
- Create reusable SVG icon components when needed - Each icon should be a separate file with a default export
- SVGs should be defined as functional components - Import icons from the icons folder
```jsx ```jsx
// ✅ CORRECT - Inline SVG as a component // ✅ CORRECT - Icon component in frontend/src/components/icons/TrashIcon.tsx
const TrashIcon = () => ( const TrashIcon = () => (
<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"> <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">
<polyline points="3 6 5 6 21 6"></polyline> <polyline points="3 6 5 6 21 6"></polyline>
@ -386,6 +386,11 @@ const TrashIcon = () => (
</svg> </svg>
); );
export default TrashIcon;
// ✅ CORRECT - Using icon component
import TrashIcon from "../components/icons/TrashIcon";
function DeleteButton() { function DeleteButton() {
return ( return (
<button className="flex items-center gap-2"> <button className="flex items-center gap-2">
@ -418,14 +423,31 @@ function DeleteButton() {
</button> </button>
); );
} }
// ❌ WRONG - Inline SVG in component file
function DeleteButton() {
return (
<button className="flex items-center gap-2">
<span className="w-4 h-4">
<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">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</span>
Delete
</button>
);
}
``` ```
**Why inline SVGs?** **Why use icon components in the icons folder?**
1. **No dependencies**: Reduces bundle size and eliminates external dependencies 1. **No dependencies**: Reduces bundle size and eliminates external dependencies
2. **Full control**: You can customize SVG properties directly in JSX 2. **Full control**: You can customize SVG properties directly in the icon component
3. **Performance**: No runtime overhead from library wrappers 3. **Performance**: No runtime overhead from library wrappers
4. **Consistency**: All icons follow to same pattern and styling 4. **Consistency**: All icons follow the same pattern and are organized in one place
5. **TypeScript support**: Full type safety without any issues 5. **Reusability**: Icons can be easily imported and reused across the application
6. **Maintainability**: Icon components are centralized and easy to update
7. **TypeScript support**: Full type safety without any issues
## Routing Rules ## Routing Rules
@ -802,7 +824,7 @@ export function useNewContext() {
**NEVER** hardcode API URLs **NEVER** hardcode API URLs
**NEVER** skip PropTypes validation **NEVER** skip PropTypes validation
**NEVER** use `<a>` for navigation (use `<Link>`) **NEVER** use `<a>` for navigation (use `<Link>`)
**NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use inline SVGs) **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use icon components from frontend/src/components/icons/)
## Checklist Before Committing ## Checklist Before Committing

View file

@ -17,6 +17,9 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"slate": "^0.123.0",
"slate-history": "^0.113.1",
"slate-react": "^0.123.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@ -1055,6 +1058,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2776,6 +2784,11 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/compute-scroll-into-view": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -3067,6 +3080,18 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/direction": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
"integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==",
"bin": {
"direction": "cli.js"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dlv": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@ -4496,6 +4521,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"node_modules/is-map": { "node_modules/is-map": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@ -4554,6 +4584,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": { "node_modules/is-potential-custom-element-name": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@ -4931,6 +4969,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -6220,6 +6263,14 @@
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
}, },
"node_modules/scroll-into-view-if-needed": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
"dependencies": {
"compute-scroll-into-view": "^3.0.2"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -6400,6 +6451,59 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/slate": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate/-/slate-0.123.0.tgz",
"integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg=="
},
"node_modules/slate-dom": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.123.0.tgz",
"integrity": "sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==",
"peer": true,
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
"direction": "^1.0.4",
"is-hotkey": "^0.2.0",
"is-plain-object": "^5.0.0",
"lodash": "^4.17.21",
"scroll-into-view-if-needed": "^3.1.0",
"tiny-invariant": "1.3.1"
},
"peerDependencies": {
"slate": ">=0.121.0"
}
},
"node_modules/slate-history": {
"version": "0.113.1",
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz",
"integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"peerDependencies": {
"slate": ">=0.65.3"
}
},
"node_modules/slate-react": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.123.0.tgz",
"integrity": "sha512-nQwXL1FEacrY9ZFmatRhoBnsySNUX2x6qB77V3oNHd7wWxBJWuzz4GMrBXcVoRE8Gac7Angf8xaNGzb6zcPlHg==",
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
"direction": "^1.0.4",
"is-hotkey": "^0.2.0",
"lodash": "^4.17.21",
"scroll-into-view-if-needed": "^3.1.0",
"tiny-invariant": "1.3.1"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0",
"slate": ">=0.121.0",
"slate-dom": ">=0.119.1"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -6744,6 +6848,11 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View file

@ -23,6 +23,9 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"slate": "^0.123.0",
"slate-history": "^0.113.1",
"slate-react": "^0.123.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {

View file

@ -17,7 +17,15 @@ import { BoardCreate } from './pages/BoardCreate';
import { BoardEdit } from './pages/BoardEdit'; import { BoardEdit } from './pages/BoardEdit';
import { BoardDetail } from './pages/BoardDetail'; import { BoardDetail } from './pages/BoardDetail';
import { BoardEpics } from './pages/BoardEpics'; import { BoardEpics } from './pages/BoardEpics';
import CreateEpic from './pages/CreateEpic';
import { EpicDetail } from './pages/EpicDetail';
import { EditEpic } from './pages/EditEpic';
import { BoardWikis } from './pages/BoardWikis';
import CreateWiki from './pages/CreateWiki';
import { WikiDetail } from './pages/WikiDetail';
import { EditWiki } from './pages/EditWiki';
import { CardDetail } from './pages/CardDetail'; import { CardDetail } from './pages/CardDetail';
import { BoardDetailLayout } from './components/BoardDetailLayout';
const App = () => { const App = () => {
const { token } = useApp(); const { token } = useApp();
@ -73,7 +81,9 @@ const App = () => {
path="/boards/:id/edit" path="/boards/:id/edit"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<BoardDetailLayout>
<BoardEdit /> <BoardEdit />
</BoardDetailLayout>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@ -81,7 +91,9 @@ const App = () => {
path="/boards/:id" path="/boards/:id"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<BoardDetailLayout>
<BoardDetail /> <BoardDetail />
</BoardDetailLayout>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@ -89,7 +101,79 @@ const App = () => {
path="/boards/:id/epics" path="/boards/:id/epics"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<BoardDetailLayout>
<BoardEpics /> <BoardEpics />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/new"
element={
<ProtectedRoute>
<BoardDetailLayout>
<CreateEpic />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/:epicId"
element={
<ProtectedRoute>
<BoardDetailLayout>
<EpicDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/:epicId/edit"
element={
<ProtectedRoute>
<BoardDetailLayout>
<EditEpic />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis"
element={
<ProtectedRoute>
<BoardDetailLayout>
<BoardWikis />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/new"
element={
<ProtectedRoute>
<BoardDetailLayout>
<CreateWiki />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/:wikiId"
element={
<ProtectedRoute>
<BoardDetailLayout>
<WikiDetail />
</BoardDetailLayout>
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/:wikiId/edit"
element={
<ProtectedRoute>
<BoardDetailLayout>
<EditWiki />
</BoardDetailLayout>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@ -97,7 +181,9 @@ const App = () => {
path="/boards/:id/cards/:cardId" path="/boards/:id/cards/:cardId"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<BoardDetailLayout>
<CardDetail /> <CardDetail />
</BoardDetailLayout>
</ProtectedRoute> </ProtectedRoute>
} }
/> />

View file

@ -0,0 +1,18 @@
import { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { BoardSidebar } from './BoardSidebar';
export const BoardDetailLayout = ({ children }: { children: ReactNode }) => {
const { id } = useParams<{ id: string }>();
return (
<div className="relative">
<div className="pr-6">{children}</div>
{id && (
<div className="">
<BoardSidebar boardId={id} />
</div>
)}
</div>
);
};

View file

@ -9,32 +9,34 @@ export function BoardSidebar({ boardId }: BoardSidebarProps) {
const menuItems = [ const menuItems = [
{ id: 'epics', label: 'Epics', icon: '📋', path: `/boards/${boardId}/epics` }, { id: 'epics', label: 'Epics', icon: '📋', path: `/boards/${boardId}/epics` },
{ id: 'wikis', label: 'Wikis', icon: '📚', path: `/boards/${boardId}/wikis` },
{ id: 'history', label: 'History', icon: '📜', path: `/boards/${boardId}/history` }, { id: 'history', label: 'History', icon: '📜', path: `/boards/${boardId}/history` },
{ id: 'documents', label: 'Documents', icon: '📄', path: `/boards/${boardId}/documents` },
]; ];
return ( return (
<div className="w-64 bg-gray-800 rounded-lg p-4 border border-gray-700 h-fit sticky top-4"> <div className="fixed right-0 top-1/2 -translate-y-1/2 z-40">
<h3 className="text-white font-bold text-lg mb-4">Board Menu</h3> <div className="flex flex-col gap-2">
<nav className="space-y-2">
{menuItems.map((item) => { {menuItems.map((item) => {
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
return ( return (
<Link <Link
key={item.id} key={item.id}
to={item.path} to={item.path}
className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${ className={`
flex items-center gap-3 px-4 py-3 rounded-l-lg transition-all duration-200
${
isActive isActive
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white' : 'bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'
}`} }
`}
> >
<span className="text-lg">{item.icon}</span> <span className="text-xl">{item.icon}</span>
<span className="font-medium">{item.label}</span> <span className="font-medium">{item.label}</span>
</Link> </Link>
); );
})} })}
</nav> </div>
</div> </div>
); );
} }

View file

@ -0,0 +1,159 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useCardEpics } from '../hooks/useCardEpics';
import type { EpicBasic } from '../types/kanban';
import PlusIcon from './icons/PlusIcon';
import UnlinkIcon from './icons/UnlinkIcon';
import ChevronRightIcon from './icons/ChevronRightIcon';
import CheckIcon from './icons/CheckIcon';
interface CardEpicsProps {
cardEpics: EpicBasic[];
boardId: number;
cardId: number;
refetchCard: () => void;
}
export function CardEpics({ cardEpics, boardId, cardId, refetchCard }: CardEpicsProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { epics, addEpic, removeEpic } = useCardEpics(boardId, cardId);
const handleAddEpic = async (epicId: number) => {
const success = await addEpic(epicId);
if (success) {
await refetchCard();
setIsDropdownOpen(false);
}
};
const handleRemoveEpic = async (epicId: number, epicName: string) => {
if (!confirm(`Unlink epic "${epicName}" from this card?`)) {
return;
}
const success = await removeEpic(epicId, epicName);
if (success) {
await refetchCard();
}
};
// Filter out epics that are already linked to the card
const availableEpics = epics.filter(
(epic) => !cardEpics.some((cardEpic) => cardEpic.id === epic.id)
);
return (
<div className="bg-gray-800 rounded-lg overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-700 flex justify-between items-center">
<h2 className="text-lg font-semibold text-white">Epics</h2>
<span className="text-sm text-gray-400">{cardEpics.length} linked</span>
</div>
{/* Epics List */}
<div className="divide-y divide-gray-700 max-h-[400px] overflow-y-auto scrollbar-custom">
{cardEpics.length > 0 ? (
cardEpics.map((epic: EpicBasic) => (
<EpicListItem
key={epic.id}
epic={epic}
boardId={boardId}
onUnlink={() => handleRemoveEpic(epic.id, epic.name)}
/>
))
) : (
<div className="p-6 text-center">
<div className="text-4xl mb-2">📋</div>
<p className="text-gray-400 text-sm">No epics linked yet</p>
</div>
)}
</div>
{/* Add Epic Button */}
{availableEpics.length > 0 && (
<div className="px-4 py-3 border-t border-gray-700 relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-full flex items-center justify-center gap-2 text-gray-400 hover:text-white hover:bg-gray-700 px-3 py-2 rounded text-sm transition-colors"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
{cardEpics.length > 0 ? 'Link another epic' : 'Link epic'}
</button>
{/* Dropdown */}
{isDropdownOpen && (
<div className="absolute bottom-full left-4 right-4 mb-2 bg-gray-700 rounded-lg shadow-lg border border-gray-600 max-h-[200px] overflow-y-auto scrollbar-custom z-10">
{availableEpics.map((epic: EpicBasic) => (
<button
key={epic.id}
onClick={() => handleAddEpic(epic.id)}
className="w-full text-left px-3 py-2 hover:bg-gray-600 transition-colors flex items-center gap-3 border-b border-gray-600 last:border-b-0"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: epic.color || '#3b82f6' }}
/>
<span className="text-white text-sm truncate flex-1">{epic.name}</span>
<span className="w-4 h-4 text-green-400 flex-shrink-0">
<CheckIcon />
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
interface EpicListItemProps {
epic: EpicBasic;
boardId: number;
onUnlink: () => void;
}
function EpicListItem({ epic, boardId, onUnlink }: EpicListItemProps) {
const defaultColor = '#3b82f6';
const color = epic.color || defaultColor;
return (
<div className="group relative">
<Link
to={`/boards/${boardId}/epics/${epic.id}`}
className="block px-4 py-2.5 hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-3">
{/* Color Indicator */}
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
{/* Epic Name */}
<h3 className="text-white text-sm font-medium truncate flex-1">{epic.name}</h3>
{/* Chevron indicator */}
<span className="w-4 h-4 text-gray-600 group-hover:text-gray-400 transition-colors flex-shrink-0">
<ChevronRightIcon />
</span>
</div>
</Link>
{/* Unlink Button (visible on hover) */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnlink();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-gray-800 hover:bg-red-900/50 text-gray-400 hover:text-red-400 p-1 rounded border border-gray-700 hover:border-red-800"
title="Unlink epic"
>
<span className="w-3 h-3">
<UnlinkIcon />
</span>
</button>
</div>
);
}

View file

@ -12,6 +12,7 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<Link <Link
to={`/boards/${card.board_id}/cards/${card.id}`} to={`/boards/${card.board_id}/cards/${card.id}`}
onClick={onClose}
className="text-2xl font-bold text-blue-400 hover:text-blue-300 hover:underline transition-colors" className="text-2xl font-bold text-blue-400 hover:text-blue-300 hover:underline transition-colors"
> >
{card.name} {card.name}
@ -24,6 +25,24 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
</button> </button>
</div> </div>
{/* Epic */}
{card.epic && (
<div className="mb-4">
<Link
to={`/boards/${card.board_id}/epics/${card.epic.id}`}
onClick={onClose}
className="inline-flex items-center gap-2 px-3 py-1 rounded text-sm font-medium transition-colors hover:opacity-80"
style={{
backgroundColor: card.epic.color ? `${card.epic.color}33` : '#3b82f633',
color: card.epic.color || '#3b82f6',
border: `1px solid ${card.epic.color || '#3b82f6'}66`,
}}
>
<span>{card.epic.name}</span>
</Link>
</div>
)}
{/* Labels */} {/* Labels */}
{card.labels && card.labels.length > 0 && ( {card.labels && card.labels.length > 0 && (
<div className="mb-4"> <div className="mb-4">

View file

@ -0,0 +1,37 @@
const ELEMENT_TAGS: Record<string, (el: HTMLElement) => Partial<any>> = {
A: (el: HTMLElement) => ({
type: 'link',
url: el.getAttribute('href') || '',
children: [],
}),
BLOCKQUOTE: () => ({ type: 'block-quote', children: [] }),
H1: () => ({ type: 'heading-one', children: [] }),
H2: () => ({ type: 'heading-two', children: [] }),
H3: () => ({ type: 'heading-three', children: [] }),
H4: () => ({ type: 'heading-four', children: [] }),
H5: () => ({ type: 'heading-five', children: [] }),
H6: () => ({ type: 'heading-six', children: [] }),
IMG: (el: HTMLElement) => ({
type: 'image',
url: el.getAttribute('src') || '',
children: [],
}),
LI: () => ({ type: 'list-item', children: [] }),
OL: () => ({ type: 'numbered-list', children: [] }),
P: () => ({ type: 'paragraph', children: [] }),
PRE: () => ({ type: 'code-block', children: [] }),
CODE: () => ({ type: 'code-line bg-gray-900', children: [] }),
UL: () => ({ type: 'bulleted-list', children: [] }),
};
const TEXT_TAGS: Record<string, () => Record<string, boolean>> = {
CODE: () => ({ code: true }),
DEL: () => ({ strikethrough: true }),
EM: () => ({ italic: true }),
I: () => ({ italic: true }),
S: () => ({ strikethrough: true }),
STRONG: () => ({ bold: true }),
U: () => ({ underline: true }),
};
export { ELEMENT_TAGS, TEXT_TAGS };

View file

@ -0,0 +1,34 @@
import { useCallback, useMemo } from 'react';
import { createEditor, Descendant } from 'slate';
import { Slate, Editable, withReact, RenderElementProps, RenderLeafProps } from 'slate-react';
import { Leaf, SlateRenderElement, withHtml } from './slate-editor-components';
export interface RichTextContentProps {
content: Descendant[] | null;
className?: string;
}
const RichTextContent = ({ content }: any) => {
const editor = useMemo(() => withHtml(withReact(createEditor())), []);
const renderElement = useCallback(
(props: RenderElementProps) => <SlateRenderElement {...props} />,
[]
);
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
return (
<Slate editor={editor} initialValue={content}>
<div className="bg-gray-700 rounded-lg p-4 border border-gray-700">
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
readOnly
placeholder="Enter some plain text..."
/>
</div>
</Slate>
);
};
export default RichTextContent;

View file

@ -0,0 +1,130 @@
import React, { useMemo, useCallback } from 'react';
import { createEditor, Descendant, Editor } from 'slate';
import {
Slate,
Editable,
withReact as withReactPlugin,
useSlate,
RenderElementProps,
RenderLeafProps,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { CustomEditor, CustomTextKey } from './custom-types';
import {
BlockButton,
Button,
SlateIcon,
SlateIconType,
Leaf,
SlateRenderElement,
Toolbar,
withHtml,
Separator,
} from './slate-editor-components';
export interface RichTextEditorProps {
value: Descendant[];
onChange: (value: Descendant[]) => void;
placeholder?: string;
readOnly?: boolean;
className?: string;
}
interface MarkButtonProps {
format: CustomTextKey;
icon: SlateIconType;
}
const isMarkActive = (editor: CustomEditor, format: CustomTextKey) => {
const marks = Editor.marks(editor);
return marks ? marks[format] === true : false;
};
const toggleMark = (editor: CustomEditor, format: CustomTextKey) => {
const isActive = isMarkActive(editor, format);
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};
const MarkButton = ({ format, icon }: MarkButtonProps) => {
const editor = useSlate();
return (
<Button
active={isMarkActive(editor, format)}
onPointerDown={(event: any) => event.preventDefault()}
onClick={() => toggleMark(editor, format)}
>
<SlateIcon type={icon} />
</Button>
);
};
const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
placeholder = 'Type something...',
readOnly = false,
className = '',
}) => {
const editor = useMemo(() => withHtml(withHistory(withReactPlugin(createEditor()))), []);
const renderElement = useCallback(
(props: RenderElementProps) => <SlateRenderElement {...props} />,
[]
);
// const renderElement = useCallback((props: any) => {
// switch (props.element.type) {
// case "block-quote":
// return <blockquote {...props.attributes}>{props.children}</blockquote>;
// case "bulleted-list":
// return <ul {...props.attributes}>{props.children}</ul>;
// case "list-item":
// return <li {...props.attributes}>{props.children}</li>;
// case "numbered-list":
// return <ol {...props.attributes}>{props.children}</ol>;
// default:
// return <p {...props.attributes}>{props.children}</p>;
// }
// }, []);
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
return (
<div className={`bg-gray-700 rounded-lg border border-gray-700 ${className}`}>
<Slate editor={editor} initialValue={value} onChange={onChange}>
<Toolbar>
<MarkButton format="bold" icon="format_bold" />
<MarkButton format="italic" icon="format_italic" />
<MarkButton format="underline" icon="format_underlined" />
<MarkButton format="code" icon="code" />
<Separator />
<BlockButton format="heading-one" icon="looks_one" />
<BlockButton format="heading-two" icon="looks_two" />
<BlockButton format="block-quote" icon="format_quote" />
<BlockButton format="numbered-list" icon="format_list_numbered" />
<BlockButton format="bulleted-list" icon="format_list_bulleted" />
<Separator />
<BlockButton format="left" icon="format_align_left" />
<BlockButton format="center" icon="format_align_center" />
<BlockButton format="right" icon="format_align_right" />
<BlockButton format="justify" icon="format_align_justify" />
</Toolbar>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder}
readOnly={readOnly}
className="min-h-[200px] px-4 pb-8 pt-1 text-gray-100 focus:outline-none"
spellCheck
/>
</Slate>
</div>
);
};
export default RichTextEditor;

View file

@ -0,0 +1,196 @@
import { Descendant, BaseEditor, BaseRange, Range, Element } from 'slate';
import { ReactEditor } from 'slate-react';
import { HistoryEditor } from 'slate-history';
export type BlockQuoteElement = {
type: 'block-quote';
align?: string;
children: Descendant[];
};
export type BulletedListElement = {
type: 'bulleted-list';
align?: string;
children: Descendant[];
};
export type CheckListItemElement = {
type: 'check-list-item';
checked: boolean;
children: Descendant[];
};
export type EditableVoidElement = {
type: 'editable-void';
children: EmptyText[];
};
export type HeadingElement = {
type: 'heading-one';
align?: string;
children: Descendant[];
};
export type HeadingTwoElement = {
type: 'heading-two';
align?: string;
children: Descendant[];
};
export type HeadingThreeElement = {
type: 'heading-three';
align?: string;
children: Descendant[];
};
export type HeadingFourElement = {
type: 'heading-four';
align?: string;
children: Descendant[];
};
export type HeadingFiveElement = {
type: 'heading-five';
align?: string;
children: Descendant[];
};
export type HeadingSixElement = {
type: 'heading-six';
align?: string;
children: Descendant[];
};
export type ImageElement = {
type: 'image';
url: string;
children: EmptyText[];
};
export type LinkElement = { type: 'link'; url: string; children: Descendant[] };
export type ButtonElement = { type: 'button'; children: Descendant[] };
export type BadgeElement = { type: 'badge'; children: Descendant[] };
export type ListItemElement = { type: 'list-item'; children: Descendant[] };
export type NumberedListItemElement = {
type: 'numbered-list';
children: Descendant[];
};
export type MentionElement = {
type: 'mention';
character: string;
children: CustomText[];
};
export type ParagraphElement = {
type: 'paragraph';
align?: string;
children: Descendant[];
};
export type TableElement = { type: 'table'; children: TableRow[] };
export type TableCellElement = { type: 'table-cell'; children: CustomText[] };
export type TableRowElement = { type: 'table-row'; children: TableCell[] };
export type TitleElement = { type: 'title'; children: Descendant[] };
export type VideoElement = { type: 'video'; url: string; children: EmptyText[] };
export type CodeBlockElement = {
type: 'code-block';
language: string;
children: Descendant[];
};
export type CodeLineElement = {
type: 'code-line';
children: Descendant[];
};
export type CustomElementWithAlign =
| ParagraphElement
| HeadingElement
| HeadingTwoElement
| HeadingThreeElement
| HeadingFourElement
| HeadingFiveElement
| HeadingSixElement
| BlockQuoteElement
| BulletedListElement;
type CustomElement =
| BlockQuoteElement
| BulletedListElement
| CheckListItemElement
| EditableVoidElement
| HeadingElement
| HeadingTwoElement
| HeadingThreeElement
| HeadingFourElement
| HeadingFiveElement
| HeadingSixElement
| ImageElement
| LinkElement
| ButtonElement
| BadgeElement
| ListItemElement
| NumberedListItemElement
| MentionElement
| ParagraphElement
| TableElement
| TableRowElement
| TableCellElement
| TitleElement
| VideoElement
| CodeBlockElement
| CodeLineElement;
export type CustomElementType = CustomElement['type'];
export type CustomText = {
bold?: boolean;
italic?: boolean;
code?: boolean;
underline?: boolean;
strikethrough?: boolean;
// MARKDOWN PREVIEW SPECIFIC LEAF
underlined?: boolean;
title?: boolean;
list?: boolean;
hr?: boolean;
blockquote?: boolean;
text: string;
};
export type CustomTextKey = keyof Omit<CustomText, 'text'>;
export type EmptyText = {
text: string;
};
export type RenderElementPropsFor<T> = RenderElementProps & {
element: T;
};
export type CustomEditor = BaseEditor &
ReactEditor &
HistoryEditor & {
nodeToDecorations?: Map<Element, Range[]>;
};
declare module 'slate' {
interface CustomTypes {
Editor: CustomEditor;
Element: CustomElement;
Text: CustomText;
Range: BaseRange & {
[key: string]: unknown;
};
}
}

View file

@ -0,0 +1,17 @@
const CheckIcon = () => (
<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"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
);
export default CheckIcon;

View file

@ -0,0 +1,17 @@
const ChevronRightIcon = () => (
<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"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
);
export default ChevronRightIcon;

View file

@ -1,4 +1,8 @@
const Edit2Icon = () => ( interface Edit2IconProps {
className?: string;
}
const Edit2Icon = ({ className = '' }: Edit2IconProps) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="20" width="20"
@ -9,6 +13,7 @@ const Edit2Icon = () => (
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className={className}
> >
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path> <path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg> </svg>

View file

@ -1,4 +1,8 @@
const PlusIcon = () => ( interface PlusIconProps {
className?: string;
}
const PlusIcon = ({ className = '' }: PlusIconProps) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@ -9,6 +13,7 @@ const PlusIcon = () => (
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className={className}
> >
<line x1="12" y1="5" x2="12" y2="19"></line> <line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line> <line x1="5" y1="12" x2="19" y2="12"></line>

View file

@ -1,4 +1,8 @@
const Trash2Icon = () => ( interface Trash2IconProps {
className?: string;
}
const Trash2Icon = ({ className = '' }: Trash2IconProps) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@ -9,6 +13,7 @@ const Trash2Icon = () => (
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className={className}
> >
<polyline points="3 6 5 6 21 6"></polyline> <polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>

View file

@ -0,0 +1,18 @@
const UnlinkIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path>
<line x1="12" y1="2" x2="12" y2="12"></line>
</svg>
);
export default UnlinkIcon;

View file

@ -0,0 +1,16 @@
const isImageUrl = (url: string): boolean => {
if (!url) return false;
try {
new URL(url); // Validate URL format
} catch {
return false;
}
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif'];
const ext = url.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.includes(ext) : false;
};
export default isImageUrl;

View file

@ -43,6 +43,10 @@ export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
const labels = (card as any).labels || []; const labels = (card as any).labels || [];
const hasLabels = labels.length > 0; const hasLabels = labels.length > 0;
// Get epic
const epic = (card as any).epic;
const hasEpic = epic !== null && epic !== undefined;
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
@ -57,6 +61,22 @@ 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"
> >
{/* Epic Badge */}
{hasEpic && (
<div className="mb-2">
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: epic.color ? `${epic.color}33` : '#3b82f633',
color: epic.color || '#3b82f6',
border: `1px solid ${epic.color || '#3b82f6'}66`,
}}
>
{epic.name}
</span>
</div>
)}
{/* Labels */} {/* Labels */}
{hasLabels && ( {hasLabels && (
<div className="flex flex-wrap gap-1 mb-2"> <div className="flex flex-wrap gap-1 mb-2">

View file

@ -68,8 +68,8 @@ export function KanbanColumn({
}; };
return ( return (
<div className="bg-gray-800 rounded-lg p-4 min-w-[300px] max-w-[300px] border border-gray-700 flex flex-col"> <div className="bg-gray-800 rounded-lg py-4 min-w-[300px] max-w-[300px] border border-gray-700 flex flex-col max-h-[calc(100vh-280px)]">
<div className="mb-4"> <div className="mb-4 px-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
{/* Drag Handle Icon */} {/* Drag Handle Icon */}
@ -133,7 +133,7 @@ export function KanbanColumn({
> >
<div <div
ref={setNodeRef} ref={setNodeRef}
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''}`} className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''} maxc-h-[calc(100vh-280px)] overflow-auto scrollbar-custom pl-4 pr-2`}
> >
{cards.map((card) => ( {cards.map((card) => (
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} /> <KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />

View file

@ -0,0 +1,630 @@
import React, { ReactNode } from 'react';
import ReactDOM from 'react-dom';
import { RenderElementProps, RenderLeafProps, useSlate } from 'slate-react';
import {
CustomEditor,
CustomElement,
CustomElementType,
CustomElementWithAlign,
} from '../custom-types';
import { Editor, Node, Transforms, Element as SlateElement } from 'slate';
import { ELEMENT_TAGS, TEXT_TAGS } from '../ELEMENT_TAGS';
// --- Icon Types ---
export type SlateIconType =
| 'format_bold'
| 'format_italic'
| 'format_underlined'
| 'code'
| 'looks_one'
| 'looks_two'
| 'format_quote'
| 'format_list_numbered'
| 'format_list_bulleted'
| 'format_align_left'
| 'format_align_center'
| 'format_align_right'
| 'format_align_justify';
// --- SVG Icons ---
const FormatBoldIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
</svg>
);
const FormatItalicIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="19" y1="4" x2="10" y2="4"></line>
<line x1="14" y1="20" x2="5" y2="20"></line>
<line x1="15" y1="4" x2="9" y2="20"></line>
</svg>
);
const FormatUnderlinedIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"></path>
<line x1="4" y1="21" x2="20" y2="21"></line>
</svg>
);
const CodeIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
);
const LooksOneIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12h8"></path>
<path d="M4 18V6"></path>
<path d="M12 18V6"></path>
</svg>
);
const LooksTwoIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 10a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v2"></path>
<path d="M17 10a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2v2"></path>
<path d="M8 16v-6"></path>
<path d="M15 16v-6"></path>
</svg>
);
const FormatQuoteIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path>
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path>
</svg>
);
const FormatListNumberedIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="10" y1="6" x2="21" y2="6"></line>
<line x1="10" y1="12" x2="21" y2="12"></line>
<line x1="10" y1="18" x2="21" y2="18"></line>
<path d="M4 6h1v4"></path>
<path d="M4 10h2"></path>
<path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path>
</svg>
);
const FormatListBulletedIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<line x1="3" y1="6" x2="3" y2="6"></line>
<line x1="3" y1="12" x2="3" y2="12"></line>
<line x1="3" y1="18" x2="3" y2="18"></line>
<line x1="21" y1="6" x2="21" y2="6"></line>
<line x1="21" y1="12" x2="21" y2="12"></line>
<line x1="21" y1="18" x2="21" y2="18"></line>
<line x1="15" y1="6" x2="15" y2="6"></line>
<line x1="15" y1="12" x2="15" y2="12"></line>
<line x1="15" y1="18" x2="15" y2="18"></line>
</svg>
);
const FormatAlignLeftIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="17" y1="10" x2="3" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</svg>
);
const FormatAlignCenterIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="10" x2="6" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="18" y1="18" x2="6" y2="18"></line>
</svg>
);
const FormatAlignRightIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="21" y1="10" x2="7" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="21" y1="18" x2="7" y2="18"></line>
</svg>
);
const FormatAlignJustifyIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="21" y1="10" x2="3" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="21" y1="18" x2="3" y2="18"></line>
</svg>
);
// --- Icon Mapping ---
const iconMap: Record<SlateIconType, React.FC> = {
format_bold: FormatBoldIcon,
format_italic: FormatItalicIcon,
format_underlined: FormatUnderlinedIcon,
code: CodeIcon,
looks_one: LooksOneIcon,
looks_two: LooksTwoIcon,
format_quote: FormatQuoteIcon,
format_list_numbered: FormatListNumberedIcon,
format_list_bulleted: FormatListBulletedIcon,
format_align_left: FormatAlignLeftIcon,
format_align_center: FormatAlignCenterIcon,
format_align_right: FormatAlignRightIcon,
format_align_justify: FormatAlignJustifyIcon,
};
export const deserialize = (node: any): any[] => {
if (node.nodeType === 3) {
return [{ text: node.textContent }];
} else if (node.nodeType !== 1) {
return [];
}
const el = node as any;
const children = Array.from(node.childNodes).map(deserialize).flat();
if (el.nodeName === 'BODY') {
return children;
}
if (ELEMENT_TAGS[el.nodeName]) {
const attrs = ELEMENT_TAGS[el.nodeName](el);
return [{ ...attrs, children }];
}
if (TEXT_TAGS[el.nodeName]) {
const attrs = TEXT_TAGS[el.nodeName]();
return children.map((child) => ({ ...child, ...attrs }));
}
return children;
};
const LIST_TYPES = ['numbered-list', 'bulleted-list'] as const;
const TEXT_ALIGN_TYPES = ['left', 'center', 'right', 'justify'] as const;
type AlignType = (typeof TEXT_ALIGN_TYPES)[number];
// --- Interfaces ---
// Extend standard HTML attributes to include className, id, etc. automatically
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
active: boolean;
reversed?: boolean;
}
type ListType = (typeof LIST_TYPES)[number];
type CustomElementFormat = CustomElementType | AlignType | ListType;
const isAlignType = (format: CustomElementFormat): format is AlignType => {
return TEXT_ALIGN_TYPES.includes(format as AlignType);
};
const isListType = (format: CustomElementFormat): format is ListType => {
return LIST_TYPES.includes(format as ListType);
};
// --- Components ---
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, active, ...props }, ref) => {
// Determine color based on active state for dark theme
const colorClass = active
? 'text-blue-400 bg-gray-700/50'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/30';
return (
<button
{...props}
type="button"
ref={ref}
className={`border-none bg-transparent cursor-pointer p-2 rounded transition-colors duration-150 ${colorClass} ${className ?? ''}`}
/>
);
}
);
Button.displayName = 'Button';
// --- SlateIcon Component ---
interface SlateIconProps {
type: SlateIconType;
className?: string;
}
export const SlateIcon = React.forwardRef<HTMLSpanElement, SlateIconProps>(
({ type, className, ...props }, ref) => {
const IconComponent = iconMap[type];
return (
<span
{...props}
ref={ref}
className={`inline-flex items-center justify-center ${className ?? ''}`}
>
<IconComponent />
</span>
);
}
);
SlateIcon.displayName = 'SlateIcon';
// Keep old Icon for backward compatibility, but mark as deprecated
export const Icon = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
({ className, ...props }, ref) => (
<span
{...props}
ref={ref}
// 'material-icons' is kept as it likely comes from a global CSS link
className={`material-icons text-[18px] align-text-bottom ${className ?? ''}`}
/>
)
);
Icon.displayName = 'Icon';
export const Instruction = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
{...props}
ref={ref}
className={`whitespace-pre-wrap my-0 mx-[-20px] mb-[10px] p-[10px] px-[20px] text-[14px] bg-[#f8f8e8] ${className ?? ''}`}
/>
)
);
Instruction.displayName = 'Instruction';
export const Menu = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
{...props}
data-test-id="menu"
ref={ref}
// Replaced inline-block + margin-left logic with flex + gap
className={`flex flex-wrap items-center gap-1 ${className ?? ''}`}
/>
)
);
Menu.displayName = 'Menu';
export const Separator = () => <div className="w-px h-6 bg-gray-600 mx-1" />;
export const Portal = ({ children }: { children?: ReactNode }) => {
return typeof document === 'object' ? ReactDOM.createPortal(children, document.body) : null;
};
export const Toolbar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<Menu
{...props}
ref={ref}
className={`relative px-3 py-2 mx-0 mb-3 border-b border-gray-700 bg-gray-800/50 ${className ?? ''}`}
/>
)
);
Toolbar.displayName = 'Toolbar';
export const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
if (leaf.bold) {
children = <strong className="font-bold">{children}</strong>;
}
if (leaf.code) {
children = (
<code className="bg-gray-900 text-blue-300 px-1 py-0.5 rounded font-mono text-sm border border-gray-700">
{children}
</code>
);
}
if (leaf.italic) {
children = <em className="italic">{children}</em>;
}
if (leaf.underline) {
children = <u className="underline">{children}</u>;
}
return <span {...attributes}>{children}</span>;
};
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
return 'align' in element;
};
export const SlateRenderElement = ({ attributes, children, element }: RenderElementProps) => {
switch (element.type) {
case 'block-quote':
return (
<blockquote
className="border-l-4 border-gray-500 pl-4 italic my-4 text-gray-300"
{...attributes}
>
{children}
</blockquote>
);
case 'code-block':
return (
<pre
className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto my-4 font-mono text-sm border border-gray-700"
{...attributes}
>
<code>{children}</code>
</pre>
);
case 'bulleted-list':
return (
<ul className="list-disc pl-5 my-4 text-gray-200" {...attributes}>
{children}
</ul>
);
case 'heading-one':
return (
<h1 className="text-3xl font-bold my-4 text-gray-100" {...attributes}>
{children}
</h1>
);
case 'heading-two':
return (
<h2 className="text-2xl font-bold my-3 text-gray-100" {...attributes}>
{children}
</h2>
);
case 'heading-three':
return (
<h3 className="text-xl font-bold my-2 text-gray-100" {...attributes}>
{children}
</h3>
);
case 'list-item':
return (
<li className="my-1 text-gray-200" {...attributes}>
{children}
</li>
);
case 'numbered-list':
return (
<ol className="list-decimal pl-5 my-4 text-gray-200" {...attributes}>
{children}
</ol>
);
default:
return (
<div className="inline my-2 mr-2 leading-relaxed text-gray-200" {...attributes}>
{children}
</div>
);
}
};
const isBlockActive = (
editor: CustomEditor,
format: CustomElementFormat,
blockType: 'type' | 'align' = 'type'
) => {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: (n) => {
if (Node.isElement(n)) {
if (blockType === 'align' && isAlignElement(n)) {
return n.align === format;
}
return n.type === format;
}
return false;
},
})
);
return !!match;
};
const toggleBlock = (editor: CustomEditor, format: CustomElementFormat) => {
const isActive = isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type');
const isList = isListType(format);
Transforms.unwrapNodes(editor, {
match: (n) => Node.isElement(n) && isListType(n.type) && !isAlignType(format),
split: true,
});
let newProperties: Partial<SlateElement>;
if (isAlignType(format)) {
newProperties = {
align: isActive ? undefined : format,
};
} else {
newProperties = {
type: isActive ? 'paragraph' : isList ? 'list-item' : format,
};
}
Transforms.setNodes<SlateElement>(editor, newProperties);
if (!isActive && isList) {
const block = { type: format, children: [] } as any;
Transforms.wrapNodes(editor, block);
}
};
interface BlockButtonProps {
format: CustomElementFormat;
icon: SlateIconType;
}
export const BlockButton = ({ format, icon }: BlockButtonProps) => {
const editor = useSlate();
return (
<Button
active={isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type')}
onPointerDown={(event: any) => event.preventDefault()}
onClick={() => toggleBlock(editor, format)}
data-test-id={`block-button-${format}`}
>
<SlateIcon type={icon} />
</Button>
);
};
export const withHtml = (editor: CustomEditor) => {
const { insertData, isInline, isVoid } = editor;
editor.isInline = (element: any) => {
return element.type === 'link' ? true : isInline(element);
};
editor.isVoid = (element: any) => {
return element.type === 'image' ? true : isVoid(element);
};
editor.insertData = (data) => {
const html = data.getData('text/html');
if (html) {
const parsed = new DOMParser().parseFromString(html, 'text/html');
const fragment = deserialize(parsed.body);
Transforms.insertFragment(editor, fragment);
return;
}
insertData(data);
};
return editor;
};

View file

@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types'; import { RegisterData, UserData, AuthResponse } from '../types';
import { import {
Board, Board,
BoardWithDetails, BoardWithDetails,
@ -69,44 +69,6 @@ export function useApi() {
return response.data; return response.data;
}, },
// Products
getProducts: async (): Promise<ProductData[]> => {
const response = await api.get<ProductData[]>('/products');
return response.data;
},
getProduct: async (id: string): Promise<ProductData> => {
const response = await api.get<ProductData>(`/products/${id}`);
return response.data;
},
createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => {
const response = await api.post<ProductData>('/products', productData);
return response.data;
},
updateProduct: async (
id: string,
productData: Partial<ProductData>
): Promise<ProductData> => {
const response = await api.put<ProductData>(`/products/${id}`, productData);
return response.data;
},
deleteProduct: async (id: string): Promise<void> => {
await api.delete(`/products/${id}`);
},
// Orders
getOrders: async (): Promise<OrderData[]> => {
const response = await api.get<OrderData[]>('/orders');
return response.data;
},
getOrder: async (id: string): Promise<OrderData> => {
const response = await api.get<OrderData>(`/orders/${id}`);
return response.data;
},
createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => {
const response = await api.post<OrderData>('/orders', orderData);
return response.data;
},
// Boards // Boards
getBoards: async (): Promise<Board[]> => { getBoards: async (): Promise<Board[]> => {
const response = await api.get<Board[]>('/boards'); const response = await api.get<Board[]>('/boards');
@ -299,6 +261,55 @@ export function useApi() {
}); });
return response.data; return response.data;
}, },
// Epics
getEpics: async (boardId: number): Promise<any> => {
const response = await api.get(`/boards/${boardId}/epics`);
return response.data;
},
createEpic: async (boardId: number, epicData: any): Promise<any> => {
const response = await api.post(`/boards/${boardId}/epics`, epicData);
return response.data;
},
getEpic: async (epicId: number): Promise<any> => {
const response = await api.get(`/epics/${epicId}`);
return response.data;
},
updateEpic: async (epicId: number, epicData: any): Promise<any> => {
const response = await api.put(`/epics/${epicId}`, epicData);
return response.data;
},
deleteEpic: async (epicId: number): Promise<void> => {
await api.delete(`/epics/${epicId}`);
},
addEpicToCard: async (cardId: number, epicId: number): Promise<any> => {
const response = await api.post(`/cards/${cardId}/epics`, { epic_id: epicId });
return response.data;
},
removeEpicFromCard: async (cardId: number, epicId: number): Promise<void> => {
await api.delete(`/cards/${cardId}/epics/${epicId}`);
},
// Wikis
getWikis: async (boardId: number): Promise<any> => {
const response = await api.get(`/boards/${boardId}/wikis`);
return response.data;
},
createWiki: async (boardId: number, wikiData: any): Promise<any> => {
const response = await api.post(`/boards/${boardId}/wikis`, wikiData);
return response.data;
},
getWiki: async (wikiId: number): Promise<any> => {
const response = await api.get(`/wikis/${wikiId}`);
return response.data;
},
updateWiki: async (wikiId: number, wikiData: any): Promise<any> => {
const response = await api.put(`/wikis/${wikiId}`, wikiData);
return response.data;
},
deleteWiki: async (wikiId: number): Promise<void> => {
await api.delete(`/wikis/${wikiId}`);
},
}), }),
[] []
); );

View file

@ -0,0 +1,100 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { EpicBasic } from '../types/kanban';
export function useCardEpics(boardId: number, cardId: number) {
const [epics, setEpics] = useState<EpicBasic[]>([]);
const [error, setError] = useState<Error | null>(null);
const { getEpics, addEpicToCard, removeEpicFromCard } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchEpics = useCallback(async () => {
try {
setError(null);
const data = await getEpics(boardId);
setEpics(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load epics';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Epics',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [getEpics, boardId, addNotification]);
const addEpic = useCallback(
async (epicId: number) => {
try {
setError(null);
await withLoader(() => addEpicToCard(cardId, epicId), 'Linking epic...');
addNotification({
type: 'success',
title: 'Epic Linked',
message: 'Epic linked to card successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to link epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Linking Epic',
message: errorMessage,
duration: 5000,
});
return false;
}
},
[cardId, addEpicToCard, withLoader, addNotification]
);
const removeEpic = useCallback(
async (epicId: number) => {
try {
setError(null);
await withLoader(() => removeEpicFromCard(cardId, epicId), 'Unlinking epic...');
addNotification({
type: 'success',
title: 'Epic Unlinked',
message: 'Epic unlinked from card successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to unlink epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Unlinking Epic',
message: errorMessage,
duration: 5000,
});
return false;
}
},
[cardId, removeEpicFromCard, withLoader, addNotification]
);
useEffect(() => {
fetchEpics();
}, [fetchEpics]);
return {
epics,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
fetchEpics,
addEpic,
removeEpic,
};
}

View file

@ -0,0 +1,105 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Epic, UpdateEpicRequest } from '../types/epic';
function useEpicDetail(epicId: string) {
const [epic, setEpic] = useState<Epic | null>(null);
const [error, setError] = useState<Error | null>(null);
const { getEpic, updateEpic: apiUpdateEpic, deleteEpic: apiDeleteEpic } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchEpic = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getEpic(Number(epicId)), 'Loading epic...');
setEpic(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Epic',
message: errorMessage,
duration: 5000,
});
return null;
}
}, [epicId, getEpic, withLoader, addNotification]);
const updateEpic = useCallback(
async (epicData: UpdateEpicRequest) => {
try {
setError(null);
const updatedEpic = await withLoader(
() => apiUpdateEpic(Number(epicId), epicData),
'Updating epic...'
);
setEpic(updatedEpic);
addNotification({
type: 'success',
title: 'Epic Updated',
message: 'Epic updated successfully.',
duration: 3000,
});
return updatedEpic;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Updating Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[epicId, apiUpdateEpic, withLoader, addNotification]
);
const deleteEpic = useCallback(async () => {
try {
setError(null);
await withLoader(() => apiDeleteEpic(Number(epicId)), 'Deleting epic...');
addNotification({
type: 'success',
title: 'Epic Deleted',
message: 'Epic deleted successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Deleting Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
}, [epicId, apiDeleteEpic, withLoader, addNotification]);
useEffect(() => {
if (epicId) {
fetchEpic();
}
}, [epicId, fetchEpic]);
return {
epic,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
updateEpic,
deleteEpic,
refetch: fetchEpic,
};
}
export default useEpicDetail;

View file

@ -0,0 +1,144 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Epic, CreateEpicRequest, UpdateEpicRequest } from '../types/epic';
function useEpics(boardId: string) {
const [epics, setEpics] = useState<Epic[]>([]);
const [error, setError] = useState<Error | null>(null);
const {
getEpics,
createEpic: apiCreateEpic,
updateEpic: apiUpdateEpic,
deleteEpic: apiDeleteEpic,
} = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchEpics = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getEpics(Number(boardId)), 'Loading epics...');
setEpics(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load epics';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Epics',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [boardId, getEpics, withLoader, addNotification]);
const createEpic = useCallback(
async (epicData: CreateEpicRequest) => {
try {
setError(null);
const newEpic = await withLoader(
() => apiCreateEpic(Number(boardId), epicData),
'Creating epic...'
);
setEpics((prev) => [...prev, newEpic]);
addNotification({
type: 'success',
title: 'Epic Created',
message: `Epic "${epicData.name}" created successfully.`,
duration: 3000,
});
return newEpic;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Creating Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[boardId, apiCreateEpic, withLoader, addNotification]
);
const updateEpic = useCallback(
async (epicId: number, epicData: UpdateEpicRequest) => {
try {
setError(null);
const updatedEpic = await withLoader(
() => apiUpdateEpic(epicId, epicData),
'Updating epic...'
);
setEpics((prev) => prev.map((epic) => (epic.id === epicId ? updatedEpic : epic)));
addNotification({
type: 'success',
title: 'Epic Updated',
message: 'Epic updated successfully.',
duration: 3000,
});
return updatedEpic;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Updating Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[apiUpdateEpic, withLoader, addNotification]
);
const deleteEpic = useCallback(
async (epicId: number) => {
try {
setError(null);
await withLoader(() => apiDeleteEpic(epicId), 'Deleting epic...');
setEpics((prev) => prev.filter((epic) => epic.id !== epicId));
addNotification({
type: 'success',
title: 'Epic Deleted',
message: 'Epic deleted successfully.',
duration: 3000,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Deleting Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[apiDeleteEpic, withLoader, addNotification]
);
useEffect(() => {
fetchEpics();
}, [fetchEpics]);
return {
epics,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
fetchEpics,
createEpic,
updateEpic,
deleteEpic,
refetch: fetchEpics,
};
}
export default useEpics;

View file

@ -1,61 +0,0 @@
import { useState, useEffect } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { ProductData } from '../types';
export function useProducts() {
const [products, setProducts] = useState<ProductData[]>([]);
const [error, setError] = useState<string | null>(null);
const { getProducts } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchProducts = async () => {
try {
setError(null);
// Use withLoader to show loading state and handle errors
const data = await withLoader(() => getProducts(), 'Loading products...');
setProducts(data);
// // Show success toast
// addNotification({
// type: 'success',
// title: 'Products Loaded',
// message: `Successfully loaded ${data.length} products.`,
// duration: 3000,
// });
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load products';
setError(errorMessage);
// Show error toast
addNotification({
type: 'error',
title: 'Error Loading Products',
message: errorMessage,
duration: 5000,
});
return [];
}
};
// Optionally auto-fetch on mount
useEffect(() => {
fetchProducts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
products,
error,
loading: false, // Loading is handled by the global loader
refetch: fetchProducts,
};
}

View file

@ -0,0 +1,105 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Wiki, UpdateWikiRequest } from '../types/epic';
function useWikiDetail(wikiId: string) {
const [wiki, setWiki] = useState<Wiki | null>(null);
const [error, setError] = useState<Error | null>(null);
const { getWiki, updateWiki: apiUpdateWiki, deleteWiki: apiDeleteWiki } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchWiki = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getWiki(Number(wikiId)), 'Loading wiki...');
setWiki(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Wiki',
message: errorMessage,
duration: 5000,
});
return null;
}
}, [wikiId, getWiki, withLoader, addNotification]);
const updateWiki = useCallback(
async (wikiData: UpdateWikiRequest) => {
try {
setError(null);
const updatedWiki = await withLoader(
() => apiUpdateWiki(Number(wikiId), wikiData),
'Updating wiki...'
);
setWiki(updatedWiki);
addNotification({
type: 'success',
title: 'Wiki Updated',
message: 'Wiki updated successfully.',
duration: 3000,
});
return updatedWiki;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Updating Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[wikiId, apiUpdateWiki, withLoader, addNotification]
);
const deleteWiki = useCallback(async () => {
try {
setError(null);
await withLoader(() => apiDeleteWiki(Number(wikiId)), 'Deleting wiki...');
addNotification({
type: 'success',
title: 'Wiki Deleted',
message: 'Wiki deleted successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Deleting Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
}, [wikiId, apiDeleteWiki, withLoader, addNotification]);
useEffect(() => {
if (wikiId) {
fetchWiki();
}
}, [wikiId, fetchWiki]);
return {
wiki,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
updateWiki,
deleteWiki,
refetch: fetchWiki,
};
}
export default useWikiDetail;

View file

@ -0,0 +1,144 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Wiki, CreateWikiRequest, UpdateWikiRequest } from '../types/epic';
function useWikis(boardId: string) {
const [wikis, setWikis] = useState<Wiki[]>([]);
const [error, setError] = useState<Error | null>(null);
const {
getWikis,
createWiki: apiCreateWiki,
updateWiki: apiUpdateWiki,
deleteWiki: apiDeleteWiki,
} = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchWikis = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getWikis(Number(boardId)), 'Loading wikis...');
setWikis(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load wikis';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Wikis',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [boardId, getWikis, withLoader, addNotification]);
const createWiki = useCallback(
async (wikiData: CreateWikiRequest) => {
try {
setError(null);
const newWiki = await withLoader(
() => apiCreateWiki(Number(boardId), wikiData),
'Creating wiki...'
);
setWikis((prev) => [...prev, newWiki]);
addNotification({
type: 'success',
title: 'Wiki Created',
message: `Wiki "${wikiData.name}" created successfully.`,
duration: 3000,
});
return newWiki;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Creating Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[boardId, apiCreateWiki, withLoader, addNotification]
);
const updateWiki = useCallback(
async (wikiId: number, wikiData: UpdateWikiRequest) => {
try {
setError(null);
const updatedWiki = await withLoader(
() => apiUpdateWiki(wikiId, wikiData),
'Updating wiki...'
);
setWikis((prev) => prev.map((wiki) => (wiki.id === wikiId ? updatedWiki : wiki)));
addNotification({
type: 'success',
title: 'Wiki Updated',
message: 'Wiki updated successfully.',
duration: 3000,
});
return updatedWiki;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Updating Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[apiUpdateWiki, withLoader, addNotification]
);
const deleteWiki = useCallback(
async (wikiId: number) => {
try {
setError(null);
await withLoader(() => apiDeleteWiki(wikiId), 'Deleting wiki...');
setWikis((prev) => prev.filter((wiki) => wiki.id !== wikiId));
addNotification({
type: 'success',
title: 'Wiki Deleted',
message: 'Wiki deleted successfully.',
duration: 3000,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Deleting Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[apiDeleteWiki, withLoader, addNotification]
);
useEffect(() => {
fetchWikis();
}, [fetchWikis]);
return {
wikis,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
fetchWikis,
createWiki,
updateWiki,
deleteWiki,
refetch: fetchWikis,
};
}
export default useWikis;

View file

@ -1,3 +1,28 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Custom scrollbar styles for dark theme */
.scrollbar-custom::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-custom::-webkit-scrollbar-track {
background: #1f2937; /* Tailwind gray-800 - dark track */
border-radius: 4px;
}
.scrollbar-custom::-webkit-scrollbar-thumb {
background: #4b5563; /* Tailwind gray-600 - medium thumb */
border-radius: 4px;
}
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #6b7280; /* Tailwind gray-500 - lighter on hover */
}
.scrollbar-custom {
scrollbar-width: thin;
scrollbar-color: #4b5563 #1f2937; /* thumb, track - dark theme colors */
}

View file

@ -5,7 +5,6 @@ import { useListMutations } from '../hooks/useListMutations';
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn'; import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
import { CreateListModal } from '../components/kanban/CreateListModal'; import { CreateListModal } from '../components/kanban/CreateListModal';
import { CardPreviewModal } from '../components/CardPreviewModal'; import { CardPreviewModal } from '../components/CardPreviewModal';
import { BoardSidebar } from '../components/BoardSidebar';
import { useModal } from '../context/modals/useModal'; import { useModal } from '../context/modals/useModal';
import { import {
DndContext, DndContext,
@ -289,8 +288,6 @@ export function BoardDetail() {
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
</div> </div>
<BoardSidebar boardId={id || ''} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,12 +1,31 @@
import { useParams, Link } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { WidePageLayout } from '../components/WidePageLayout'; import { WidePageLayout } from '../components/WidePageLayout';
import useEpics from '../hooks/useEpics';
import RichTextContent from '../components/RichTextContent';
import type { Epic } from '../types/epic';
import PlusIcon from '../components/icons/PlusIcon';
import Edit2Icon from '../components/icons/Edit2Icon';
import Trash2Icon from '../components/icons/Trash2Icon';
export function BoardEpics() { export function BoardEpics() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { epics, deleteEpic } = useEpics(id || '0');
const handleDeleteEpic = async (epicId: number, epicName: string) => {
if (confirm(`Are you sure you want to delete epic "${epicName}"?`)) {
try {
await deleteEpic(epicId);
} catch {
// Error is handled by the hook
}
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<WidePageLayout> <WidePageLayout>
<div className="flex items-center justify-between">
<div> <div>
<Link <Link
to={`/boards/${id}`} to={`/boards/${id}`}
@ -15,21 +34,150 @@ export function BoardEpics() {
Back to Board Back to Board
</Link> </Link>
<h1 className="text-3xl font-bold text-white mt-2">Epics</h1> <h1 className="text-3xl font-bold text-white mt-2">Epics</h1>
<p className="text-gray-400 mt-1">Manage and view epics for this board</p> <p className="text-gray-400 mt-1">
Manage and view epics for this board ({epics.length} epics)
</p>
</div>
<Link
to={`/boards/${id}/epics/new`}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<PlusIcon className="w-4 h-4" />
Create Epic
</Link>
</div> </div>
</WidePageLayout> </WidePageLayout>
<WidePageLayout> <WidePageLayout>
{epics.length === 0 ? (
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700"> <div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-6xl mb-4">📋</div> <div className="text-6xl mb-4">📋</div>
<h2 className="text-xl font-bold text-white mb-2">Epics Coming Soon</h2> <h2 className="text-xl font-bold text-white mb-2">No Epics Yet</h2>
<p className="text-gray-400"> <p className="text-gray-400 mb-6">
This page will allow you to create and manage epics for your board. Create your first epic to start organizing your work.
</p> </p>
<Link
to={`/boards/${id}/epics/new`}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg inline-flex items-center gap-2 transition-colors"
>
<PlusIcon className="w-4 h-4" />
Create First Epic
</Link>
</div> </div>
</div> </div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{epics.map((epic) => (
<EpicCard
key={epic.id}
epic={epic}
onClick={() => navigate(`/boards/${id}/epics/${epic.id}`)}
onDelete={() => handleDeleteEpic(epic.id, epic.name)}
onEdit={() => navigate(`/boards/${id}/epics/${epic.id}/edit`)}
/>
))}
</div>
)}
</WidePageLayout> </WidePageLayout>
</div> </div>
); );
} }
interface EpicCardProps {
epic: Epic;
onClick: () => void;
onDelete: () => void;
onEdit: () => void;
}
function EpicCard({ epic, onClick, onDelete, onEdit }: EpicCardProps) {
const defaultColor = '#3b82f6';
const color = epic.color || defaultColor;
return (
<div
className="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-all cursor-pointer overflow-hidden group"
onClick={onClick}
>
{/* Color header */}
<div className="h-2" style={{ backgroundColor: color }} />
<div className="p-4">
{/* Header with actions */}
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-white flex-1">{epic.name}</h3>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="text-gray-400 hover:text-white transition-colors p-1"
title="Edit epic"
>
<Edit2Icon className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-gray-400 hover:text-red-400 transition-colors p-1"
title="Delete epic"
>
<Trash2Icon className="w-4 h-4" />
</button>
</div>
</div>
{/* Description */}
{epic.description && (
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{epic.description}</p>
)}
{/* Rich text content preview */}
{epic.content && Array.isArray(epic.content) && epic.content.length > 0 && (
<div className="mb-3 max-h-[120px] overflow-hidden relative">
<RichTextContent
content={epic.content}
className="text-sm text-gray-400 line-clamp-3"
/>
{/* Fade overlay at bottom */}
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-gray-800 to-transparent pointer-events-none" />
</div>
)}
{/* Footer with metrics */}
<div className="flex items-center justify-between text-xs text-gray-500 mt-3 pt-3 border-t border-gray-700">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<span>📊</span>
{epic.metrics?.card_count || 0} cards
</span>
{epic.parent_epic_id && (
<span className="flex items-center gap-1">
<span>🔄</span>
Sub-epic
</span>
)}
</div>
{epic.date_last_activity && (
<span>{new Date(epic.date_last_activity).toLocaleDateString()}</span>
)}
</div>
{/* Closed badge */}
{epic.closed && (
<div className="mt-3">
<span className="inline-block px-2 py-1 bg-gray-700 text-gray-400 text-xs rounded">
Closed
</span>
</div>
)}
</div>
</div>
);
}
export default BoardEpics;

View file

@ -0,0 +1,158 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent';
import useWikis from '../hooks/useWikis';
import type { Wiki } from '../types/epic';
import PlusIcon from '../components/icons/PlusIcon';
import Edit2Icon from '../components/icons/Edit2Icon';
import Trash2Icon from '../components/icons/Trash2Icon';
export function BoardWikis() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { wikis, deleteWiki } = useWikis(id || '0');
const handleDeleteWiki = async (wikiId: number, wikiName: string) => {
if (confirm(`Are you sure you want to delete wiki "${wikiName}"?`)) {
try {
await deleteWiki(wikiId);
} catch {
// Error is handled by the hook
}
}
};
return (
<div className="space-y-6">
<WidePageLayout>
<div className="flex items-center justify-between">
<div>
<Link
to={`/boards/${id}`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Board
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Wikis</h1>
<p className="text-gray-400 mt-1">
Manage and view wikis for this board ({wikis.length} wikis)
</p>
</div>
<Link
to={`/boards/${id}/wikis/new`}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<PlusIcon className="w-4 h-4" />
Create Wiki
</Link>
</div>
</WidePageLayout>
<WidePageLayout>
{wikis.length === 0 ? (
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
<div className="text-center py-12">
<div className="text-6xl mb-4">📚</div>
<h2 className="text-xl font-bold text-white mb-2">No Wikis Yet</h2>
<p className="text-gray-400 mb-6">
Create your first wiki to start documenting your knowledge.
</p>
<Link
to={`/boards/${id}/wikis/new`}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg inline-flex items-center gap-2 transition-colors"
>
<PlusIcon className="w-4 h-4" />
Create First Wiki
</Link>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{wikis.map((wiki) => (
<WikiCard
key={wiki.id}
wiki={wiki}
onClick={() => navigate(`/boards/${id}/wikis/${wiki.id}`)}
onDelete={() => handleDeleteWiki(wiki.id, wiki.name)}
onEdit={() => navigate(`/boards/${id}/wikis/${wiki.id}/edit`)}
/>
))}
</div>
)}
</WidePageLayout>
</div>
);
}
interface WikiCardProps {
wiki: Wiki;
onClick: () => void;
onDelete: () => void;
onEdit: () => void;
}
function WikiCard({ wiki, onClick, onDelete, onEdit }: WikiCardProps) {
return (
<div
className="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-all cursor-pointer overflow-hidden group"
onClick={onClick}
>
<div className="p-4">
{/* Header with actions */}
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-white flex-1">{wiki.name}</h3>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="text-gray-400 hover:text-white transition-colors p-1"
title="Edit wiki"
>
<Edit2Icon className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-gray-400 hover:text-red-400 transition-colors p-1"
title="Delete wiki"
>
<Trash2Icon className="w-4 h-4" />
</button>
</div>
</div>
{/* Summary */}
{wiki.summary && <p className="text-gray-400 text-sm mb-3 line-clamp-2">{wiki.summary}</p>}
{/* Rich text content preview */}
{wiki.content && Array.isArray(wiki.content) && wiki.content.length > 0 && (
<div className="mb-3 max-h-[120px] overflow-hidden relative">
<RichTextContent
content={wiki.content}
className="text-sm text-gray-400 line-clamp-3"
/>
{/* Fade overlay at bottom */}
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-gray-800 to-transparent pointer-events-none" />
</div>
)}
{/* Footer with metadata */}
<div className="flex items-center justify-between text-xs text-gray-500 mt-3 pt-3 border-t border-gray-700">
<div className="flex items-center gap-4">
{wiki.category && (
<span className="px-2 py-1 bg-gray-700 text-gray-400 rounded">{wiki.category}</span>
)}
{wiki.tags && wiki.tags.length > 0 && <span>{wiki.tags.length} tag(s)</span>}
</div>
{wiki.updated_at && <span>{new Date(wiki.updated_at).toLocaleDateString()}</span>}
</div>
</div>
</div>
);
}
export default BoardWikis;

View file

@ -9,6 +9,7 @@ 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 { CardAttachments } from '../components/CardAttachments'; import { CardAttachments } from '../components/CardAttachments';
import { EditCardModal } from '../components/EditCardModal'; import { EditCardModal } from '../components/EditCardModal';
import { DeleteCardModal } from '../components/DeleteCardModal'; import { DeleteCardModal } from '../components/DeleteCardModal';
@ -154,6 +155,14 @@ export function CardDetail() {
refetchCard={fetchCard} refetchCard={fetchCard}
/> />
{/* Epic Section */}
<CardEpics
cardEpics={card.epic ? [card.epic] : []}
boardId={parseInt(boardId || '0')}
cardId={parseInt(cardId || '0')}
refetchCard={fetchCard}
/>
{/* Checklists Section */} {/* Checklists Section */}
<CardChecklists <CardChecklists
checklists={card.checklists || []} checklists={card.checklists || []}

View file

@ -0,0 +1,217 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextEditor from '../components/RichTextEditor';
import useEpics from '../hooks/useEpics';
import { useBoard } from '../hooks/useBoard';
import CheckSquareIcon from '../components/icons/CheckSquareIcon';
import type { CreateEpicRequest } from '../types/epic';
const COLOR_PRESETS = [
'#ef4444', // Red
'#f97316', // Orange
'#eab308', // Yellow
'#22c55e', // Green
'#14b8a6', // Teal
'#3b82f6', // Blue
'#8b5cf6', // Purple
'#ec4899', // Pink
'#6b7280', // Gray
];
export function CreateEpic() {
const { id: boardId } = useParams<{ id: string }>();
const navigate = useNavigate();
const { createEpic } = useEpics(boardId || '0');
const { board } = useBoard(parseInt(boardId || '0', 10));
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState(COLOR_PRESETS[5]); // Default to blue
const [content, setContent] = useState<any[]>([{ children: [{ text: '' }] }]);
const [parentEpicId, setParentEpicId] = useState<number | undefined>(undefined);
const [completedListId, setCompletedListId] = useState<number | undefined>(undefined);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
return;
}
const epicData: CreateEpicRequest = {
name: name.trim(),
description: description.trim() || undefined,
content:
content.length > 1 || (content[0] as any).children[0].text !== ''
? (content as unknown as Record<string, unknown>)
: undefined,
color,
parent_epic_id: parentEpicId,
completed_list_id: completedListId,
};
try {
const newEpic = await createEpic(epicData);
navigate(`/boards/${boardId}/epics/${newEpic.id}`);
} catch {
// Error is handled by hook
}
};
return (
<div className="space-y-6">
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/epics`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Epics
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Create Epic</h1>
<p className="text-gray-400 mt-1">Create a new epic for this board</p>
</div>
</WidePageLayout>
<WidePageLayout>
<form onSubmit={handleSubmit} className="bg-gray-800 rounded-lg border border-gray-700 p-6">
{/* Name */}
<div className="mb-6">
<label htmlFor="name" className="block text-white font-medium mb-2">
Epic Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="Enter epic name..."
required
/>
</div>
{/* Description */}
<div className="mb-6">
<label htmlFor="description" className="block text-white font-medium mb-2">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500 resize-none"
rows={3}
placeholder="Brief description of this epic..."
/>
</div>
{/* Color */}
<div className="mb-6">
<label className="block text-white font-medium mb-2">Color</label>
<div className="flex flex-wrap gap-3">
{COLOR_PRESETS.map((presetColor) => (
<button
key={presetColor}
type="button"
onClick={() => setColor(presetColor)}
className={`w-10 h-10 rounded-lg border-2 transition-all ${
color === presetColor
? 'border-white ring-2 ring-white ring-opacity-50'
: 'border-transparent hover:border-gray-500'
}`}
style={{ backgroundColor: presetColor }}
title={presetColor}
/>
))}
</div>
</div>
{/* Rich Text Content */}
<div className="mb-6">
<label className="block text-white font-medium mb-2">Epic Details</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Add detailed information about this epic..."
className="border-0"
/>
<p className="text-gray-500 text-xs mt-2">
Use the rich text editor to add formatted content, lists, and more.
</p>
</div>
{/* Parent Epic */}
<div className="mb-6">
<label htmlFor="parentEpic" className="block text-white font-medium mb-2">
Parent Epic
</label>
<select
id="parentEpic"
value={parentEpicId || ''}
onChange={(e) =>
setParentEpicId(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
>
<option value="">No parent (top-level epic)</option>
{/* Parent epics would be loaded here */}
</select>
<p className="text-gray-500 text-xs mt-2">
Select a parent epic to create a hierarchy. Leave empty for a top-level epic.
</p>
</div>
{/* Completed List */}
<div className="mb-6">
<label htmlFor="completedList" className="block text-white font-medium mb-2">
Completed List
</label>
<select
id="completedList"
value={completedListId || ''}
onChange={(e) =>
setCompletedListId(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
>
<option value="">No completed list</option>
{board?.lists?.map((list) => (
<option key={list.id} value={list.id}>
{list.name}
</option>
))}
</select>
<p className="text-gray-500 text-xs mt-2">
Select the list that represents completed cards for this epic. Used to calculate
progress.
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-700">
<Link
to={`/boards/${boardId}/epics`}
className="px-6 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-500 transition-colors"
>
Cancel
</Link>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-6 py-2 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
Create Epic
</button>
</div>
</form>
</WidePageLayout>
</div>
);
}
export default CreateEpic;

View file

@ -0,0 +1,184 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextEditor from '../components/RichTextEditor';
import useWikis from '../hooks/useWikis';
import CheckSquareIcon from '../components/icons/CheckSquareIcon';
import type { CreateWikiRequest } from '../types/epic';
export function CreateWiki() {
const { id: boardId } = useParams<{ id: string }>();
const navigate = useNavigate();
const { createWiki } = useWikis(boardId || '0');
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [summary, setSummary] = useState('');
const [category, setCategory] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState<any[]>([{ children: [{ text: '' }] }]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
return;
}
const wikiData: CreateWikiRequest = {
name: name.trim(),
slug: slug.trim() || undefined,
summary: summary.trim() || undefined,
content:
content.length > 1 || (content[0] as any).children[0].text !== ''
? (content as unknown as Record<string, unknown>)
: { children: [{ text: '' }] },
category: category.trim() || undefined,
tags: tags.trim() ? tags.split(',').map((t) => t.trim()) : undefined,
};
try {
const newWiki = await createWiki(wikiData);
navigate(`/boards/${boardId}/wikis/${newWiki.id}`);
} catch {
// Error is handled by hook
}
};
return (
<div className="space-y-6">
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/wikis`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Wikis
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Create Wiki</h1>
<p className="text-gray-400 mt-1">Create a new wiki for this board</p>
</div>
</WidePageLayout>
<WidePageLayout>
<form onSubmit={handleSubmit} className="bg-gray-800 rounded-lg border border-gray-700 p-6">
{/* Name */}
<div className="mb-6">
<label htmlFor="name" className="block text-white font-medium mb-2">
Wiki Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="Enter wiki name..."
required
/>
</div>
{/* Slug */}
<div className="mb-6">
<label htmlFor="slug" className="block text-white font-medium mb-2">
URL Slug (optional)
</label>
<input
type="text"
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="url-friendly-slug"
/>
<p className="text-gray-500 text-xs mt-2">
Leave empty to auto-generate from name. Used in URLs.
</p>
</div>
{/* Summary */}
<div className="mb-6">
<label htmlFor="summary" className="block text-white font-medium mb-2">
Summary
</label>
<textarea
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500 resize-none"
rows={3}
placeholder="Brief summary of this wiki..."
/>
</div>
{/* Category */}
<div className="mb-6">
<label htmlFor="category" className="block text-white font-medium mb-2">
Category
</label>
<input
type="text"
id="category"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="e.g., Documentation, Guides, Tutorials"
/>
</div>
{/* Tags */}
<div className="mb-6">
<label htmlFor="tags" className="block text-white font-medium mb-2">
Tags
</label>
<input
type="text"
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="tag1, tag2, tag3"
/>
<p className="text-gray-500 text-xs mt-2">Separate multiple tags with commas.</p>
</div>
{/* Rich Text Content */}
<div className="mb-6">
<label className="block text-white font-medium mb-2">Wiki Content</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Add detailed information to your wiki..."
className="border-0"
/>
<p className="text-gray-500 text-xs mt-2">
Use the rich text editor to add formatted content, lists, and more.
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-700">
<Link
to={`/boards/${boardId}/wikis`}
className="px-6 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-500 transition-colors"
>
Cancel
</Link>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-6 py-2 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
Create Wiki
</button>
</div>
</form>
</WidePageLayout>
</div>
);
}
export default CreateWiki;

View file

@ -0,0 +1,246 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextEditor from '../components/RichTextEditor';
import useEpicDetail from '../hooks/useEpicDetail';
import { useBoard } from '../hooks/useBoard';
import CheckSquareIcon from '../components/icons/CheckSquareIcon';
import type { UpdateEpicRequest } from '../types/epic';
const COLOR_PRESETS = [
'#ef4444', // Red
'#f97316', // Orange
'#eab308', // Yellow
'#22c55e', // Green
'#14b8a6', // Teal
'#3b82f6', // Blue
'#8b5cf6', // Purple
'#ec4899', // Pink
'#6b7280', // Gray
];
export function EditEpic() {
const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>();
const navigate = useNavigate();
const { epic, updateEpic } = useEpicDetail(epicId || '0');
const { board } = useBoard(parseInt(boardId || '0', 10));
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState(COLOR_PRESETS[5]);
const [content, setContent] = useState<any[]>([]);
const [parentEpicId, setParentEpicId] = useState<number | undefined>(undefined);
const [completedListId, setCompletedListId] = useState<number | undefined>(undefined);
// Initialize form with epic data
useEffect(() => {
if (epic) {
setName(epic.name);
setDescription(epic.description || '');
setColor(epic.color || COLOR_PRESETS[5]);
if (epic.content && typeof epic.content === 'object') {
setContent(epic.content as unknown as any[]);
}
setParentEpicId(epic.parent_epic_id || undefined);
setCompletedListId(epic.completed_list_id || undefined);
}
}, [epic]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
return;
}
const epicData: UpdateEpicRequest = {
name: name.trim(),
description: description.trim() || undefined,
content:
content.length > 1 || (content[0] as any).children[0].text !== ''
? (content as unknown as Record<string, unknown>)
: undefined,
color,
parent_epic_id: parentEpicId,
completed_list_id: completedListId,
};
try {
await updateEpic(epicData);
navigate(`/boards/${boardId}/epics/${epicId}`);
} catch {
// Error is handled by hook
}
};
if (!epic) {
return (
<div className="space-y-6">
<WidePageLayout>
<div className="text-center py-12">
<div className="text-gray-400">Loading epic...</div>
</div>
</WidePageLayout>
</div>
);
}
return (
<div className="space-y-6">
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/epics/${epicId}`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Epic
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Edit Epic</h1>
<p className="text-gray-400 mt-1">Update epic information</p>
</div>
</WidePageLayout>
<WidePageLayout>
<form onSubmit={handleSubmit} className="bg-gray-800 rounded-lg border border-gray-700 p-6">
{/* Name */}
<div className="mb-6">
<label htmlFor="name" className="block text-white font-medium mb-2">
Epic Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="Enter epic name..."
required
/>
</div>
{/* Description */}
<div className="mb-6">
<label htmlFor="description" className="block text-white font-medium mb-2">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500 resize-none"
rows={3}
placeholder="Brief description of this epic..."
/>
</div>
{/* Color */}
<div className="mb-6">
<label className="block text-white font-medium mb-2">Color</label>
<div className="flex flex-wrap gap-3">
{COLOR_PRESETS.map((presetColor) => (
<button
key={presetColor}
type="button"
onClick={() => setColor(presetColor)}
className={`w-10 h-10 rounded-lg border-2 transition-all ${
color === presetColor
? 'border-white ring-2 ring-white ring-opacity-50'
: 'border-transparent hover:border-gray-500'
}`}
style={{ backgroundColor: presetColor }}
title={presetColor}
/>
))}
</div>
</div>
{/* Rich Text Content */}
<div className="mb-6">
<label className="block text-white font-medium mb-2">Epic Details</label>
{content.length}
{content.length > 0 ? (
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Add detailed information about this epic..."
className="border-0"
/>
) : null}
<p className="text-gray-500 text-xs mt-2">
Use the rich text editor to add formatted content, lists, and more.
</p>
</div>
{/* Parent Epic */}
<div className="mb-6">
<label htmlFor="parentEpic" className="block text-white font-medium mb-2">
Parent Epic
</label>
<select
id="parentEpic"
value={parentEpicId || ''}
onChange={(e) =>
setParentEpicId(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
>
<option value="">No parent (top-level epic)</option>
{/* Parent epics would be loaded here */}
</select>
<p className="text-gray-500 text-xs mt-2">
Select a parent epic to create a hierarchy. Leave empty for a top-level epic.
</p>
</div>
{/* Completed List */}
<div className="mb-6">
<label htmlFor="completedList" className="block text-white font-medium mb-2">
Completed List
</label>
<select
id="completedList"
value={completedListId || ''}
onChange={(e) =>
setCompletedListId(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
>
<option value="">No completed list</option>
{board?.lists?.map((list) => (
<option key={list.id} value={list.id}>
{list.name}
</option>
))}
</select>
<p className="text-gray-500 text-xs mt-2">
Select the list that represents completed cards for this epic. Used to calculate
progress.
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-700">
<Link
to={`/boards/${boardId}/epics/${epicId}`}
className="px-6 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-500 transition-colors"
>
Cancel
</Link>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-6 py-2 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
Save Changes
</button>
</div>
</form>
</WidePageLayout>
</div>
);
}
export default EditEpic;

View file

@ -0,0 +1,230 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextEditor from '../components/RichTextEditor';
import useWikiDetail from '../hooks/useWikiDetail';
import CheckSquareIcon from '../components/icons/CheckSquareIcon';
import type { UpdateWikiRequest } from '../types/epic';
// Form validation schema
const updateWikiSchema = z.object({
name: z.string().min(1, 'Wiki name is required'),
slug: z.string().optional(),
summary: z.string().optional(),
category: z.string().optional(),
tags: z.string().optional(),
content: z.any().optional(),
});
type UpdateWikiFormData = z.infer<typeof updateWikiSchema>;
export function EditWiki() {
const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>();
const navigate = useNavigate();
const { wiki, updateWiki } = useWikiDetail(wikiId || '0');
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
} = useForm<UpdateWikiFormData>({
resolver: zodResolver(updateWikiSchema),
defaultValues: {
name: '',
slug: '',
summary: '',
category: '',
tags: '',
content: [],
},
});
const watchedContent = watch('content');
// Initialize form with wiki data
useEffect(() => {
if (wiki) {
setValue('name', wiki.name);
setValue('slug', wiki.slug || '');
setValue('summary', wiki.summary || '');
setValue('category', wiki.category || '');
setValue('tags', wiki.tags ? wiki.tags.join(', ') : '');
if (wiki.content && typeof wiki.content === 'object') {
setValue('content', wiki.content as unknown as any[]);
}
}
}, [wiki, setValue]);
const onSubmit = async (data: UpdateWikiFormData) => {
const wikiData: UpdateWikiRequest = {
name: data.name.trim(),
slug: data.slug?.trim() || undefined,
summary: data.summary?.trim() || undefined,
category: data.category?.trim() || undefined,
tags: data.tags?.trim() ? data.tags.split(',').map((t) => t.trim()) : undefined,
content: data.content,
};
try {
await updateWiki(wikiData);
navigate(`/boards/${boardId}/wikis/${wikiId}`);
} catch {
// Error is handled by hook
}
};
if (!wiki) {
return (
<div className="space-y-6">
<WidePageLayout>
<div className="text-center py-12">
<div className="text-gray-400">Loading wiki...</div>
</div>
</WidePageLayout>
</div>
);
}
return (
<div className="space-y-6">
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/wikis/${wikiId}`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Wiki
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Edit Wiki</h1>
<p className="text-gray-400 mt-1">Update wiki information</p>
</div>
</WidePageLayout>
<WidePageLayout>
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-gray-800 rounded-lg border border-gray-700 p-6"
>
{/* Name */}
<div className="mb-6">
<label htmlFor="name" className="block text-white font-medium mb-2">
Wiki Name <span className="text-red-500">*</span>
</label>
<input
id="name"
type="text"
{...register('name')}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="Enter wiki name..."
/>
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name.message}</p>}
</div>
{/* Slug */}
<div className="mb-6">
<label htmlFor="slug" className="block text-white font-medium mb-2">
URL Slug
</label>
<input
id="slug"
type="text"
{...register('slug')}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="url-friendly-slug"
/>
<p className="text-gray-500 text-xs mt-2">
Leave empty to auto-generate from name. Used in URLs.
</p>
</div>
{/* Summary */}
<div className="mb-6">
<label htmlFor="summary" className="block text-white font-medium mb-2">
Summary
</label>
<textarea
id="summary"
{...register('summary')}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500 resize-none"
rows={3}
placeholder="Brief summary of this wiki..."
/>
</div>
{/* Category */}
<div className="mb-6">
<label htmlFor="category" className="block text-white font-medium mb-2">
Category
</label>
<input
id="category"
type="text"
{...register('category')}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="e.g., Documentation, Guides, Tutorials"
/>
</div>
{/* Tags */}
<div className="mb-6">
<label htmlFor="tags" className="block text-white font-medium mb-2">
Tags
</label>
<input
id="tags"
type="text"
{...register('tags')}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="tag1, tag2, tag3"
/>
<p className="text-gray-500 text-xs mt-2">Separate multiple tags with commas.</p>
</div>
{/* Rich Text Content */}
<div className="mb-6">
<label className="block text-white font-medium mb-2">Wiki Content</label>
{watchedContent && watchedContent.length > 0 ? (
<RichTextEditor
value={watchedContent || []}
onChange={(value) => setValue('content', value)}
placeholder="Add detailed information to your wiki..."
className="border-0"
/>
) : null}
<p className="text-gray-500 text-xs mt-2">
Use the rich text editor to add formatted content, lists, and more.
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-700">
<Link
to={`/boards/${boardId}/wikis/${wikiId}`}
className="px-6 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-500 transition-colors"
>
Cancel
</Link>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-6 py-2 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</WidePageLayout>
</div>
);
}
export default EditWiki;

View file

@ -0,0 +1,237 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent';
import useEpicDetail from '../hooks/useEpicDetail';
import Edit2Icon from '../components/icons/Edit2Icon';
import Trash2Icon from '../components/icons/Trash2Icon';
export function EpicDetail() {
const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>();
const navigate = useNavigate();
const { epic, deleteEpic } = useEpicDetail(epicId || '0');
const handleDelete = async () => {
if (!epic) return;
if (confirm(`Are you sure you want to delete epic "${epic.name}"?`)) {
try {
await deleteEpic();
navigate(`/boards/${boardId}/epics`);
} catch {
// Error is already handled by the hook with toast
}
}
};
if (!epic) {
return (
<div className="space-y-6">
<WidePageLayout>
<div className="text-center py-12">
<div className="text-gray-400">Loading epic...</div>
</div>
</WidePageLayout>
</div>
);
}
const defaultColor = '#3b82f6';
const color = epic.color || defaultColor;
return (
<div className="space-y-6">
{/* Header */}
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/epics`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Epics
</Link>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
<div className="w-1 h-12 rounded" style={{ backgroundColor: color }} />
<div>
<h1 className="text-3xl font-bold text-white">{epic.name}</h1>
{epic.closed && (
<span className="inline-block px-2 py-1 bg-gray-700 text-gray-400 text-xs rounded mt-1">
Closed
</span>
)}
</div>
</div>
<div className="flex gap-2">
<Link
to={`/boards/${boardId}/epics/${epic.id}/edit`}
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<Edit2Icon className="w-4 h-4" />
Edit
</Link>
<button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<Trash2Icon className="w-4 h-4" />
Delete
</button>
</div>
</div>
</div>
</WidePageLayout>
{/* Content */}
<WidePageLayout>
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
{/* Color header bar */}
<div className="h-2" style={{ backgroundColor: color }} />
<div className="p-6">
{/* Description */}
{epic.description && (
<div className="mb-6">
<h2 className="text-lg font-semibold text-white mb-2">Description</h2>
<p className="text-gray-300">{epic.description}</p>
</div>
)}
{/* Rich Text Content */}
{epic.content && Array.isArray(epic.content) && epic.content.length > 0 && (
<div className="mb-6">
<h2 className="text-lg font-semibold text-white mb-2">Details</h2>
<RichTextContent content={epic.content} />
</div>
)}
{/* Metrics */}
<div className="mb-6 pt-6 border-t border-gray-700">
<h2 className="text-lg font-semibold text-white mb-3">Metrics</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<span>📊</span>
<span className="text-sm">Cards</span>
</div>
<div className="text-2xl font-bold text-white">
{epic.metrics?.card_count || 0}
</div>
</div>
<div className="bg-gray-900 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<span></span>
<span className="text-sm">Completed</span>
</div>
<div className="text-2xl font-bold text-white">
{epic.metrics?.completed_cards_count || 0}
</div>
<div className="text-xs text-gray-500 mt-1">
{epic.metrics?.card_count && epic.metrics?.completed_cards_count !== undefined
? `${Math.round((epic.metrics.completed_cards_count / epic.metrics.card_count) * 100)}% complete`
: '0% complete'}
</div>
</div>
{epic.depth_limit !== null && epic.depth_limit !== undefined && (
<div className="bg-gray-900 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<span>🔄</span>
<span className="text-sm">Max Depth</span>
</div>
<div className="text-2xl font-bold text-white">{epic.depth_limit}</div>
</div>
)}
{epic.parent_epic_id && (
<div className="bg-gray-900 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<span>🔗</span>
<span className="text-sm">Parent</span>
</div>
<div className="text-lg font-bold text-white">Yes</div>
</div>
)}
</div>
</div>
{/* Metadata */}
<div className="pt-6 border-t border-gray-700">
<h2 className="text-lg font-semibold text-white mb-3">Metadata</h2>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Created:</span>
<span className="text-gray-300">
{epic.created_at ? new Date(epic.created_at).toLocaleString() : 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Updated:</span>
<span className="text-gray-300">
{epic.updated_at ? new Date(epic.updated_at).toLocaleString() : 'N/A'}
</span>
</div>
{epic.date_last_activity && (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Last Activity:</span>
<span className="text-gray-300">
{new Date(epic.date_last_activity).toLocaleString()}
</span>
</div>
)}
</div>
</div>
{/* Links to Cards */}
<div className="pt-6 border-t border-gray-700 mt-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white">Linked Cards</h2>
<Link to={`/boards/${boardId}`} className="text-blue-500 hover:underline text-sm">
View in Board
</Link>
</div>
{epic.cards && epic.cards.length > 0 ? (
<div className="space-y-2">
{epic.cards.map((card) => (
<Link
key={card.id}
to={`/boards/${boardId}/cards/${card.id}`}
className="block bg-gray-900 hover:bg-gray-700 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-gray-500 text-sm">#{card.id_short}</span>
<h3 className="text-white font-medium">{card.name}</h3>
</div>
{card.description && (
<p className="text-gray-400 text-sm line-clamp-1">{card.description}</p>
)}
</div>
{card.due && (
<span
className={`text-sm px-2 py-1 rounded ${
card.due_complete
? 'bg-green-900 text-green-300'
: 'bg-yellow-900 text-yellow-300'
}`}
>
Due: {new Date(card.due).toLocaleDateString()}
</span>
)}
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2">📭</div>
<p>No cards linked to this epic yet</p>
</div>
)}
</div>
</div>
</div>
</WidePageLayout>
</div>
);
}
export default EpicDetail;

View file

@ -1,122 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApp } from '../context/AppContext';
import { useApi } from '../hooks/useApi';
import { OrderData } from '../types';
export function Orders() {
const [orders, setOrders] = useState<OrderData[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const { user } = useApp();
const { getOrders } = useApi();
useEffect(() => {
if (!user) {
navigate('/login');
return;
}
fetchOrders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, navigate]);
const fetchOrders = async () => {
try {
const data = await getOrders();
setOrders(data);
} catch (error) {
console.error('Error fetching orders:', error);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string): string => {
const colors: Record<string, string> = {
pending: 'bg-yellow-900 text-yellow-200 border-yellow-700',
processing: 'bg-blue-900 text-blue-200 border-blue-700',
shipped: 'bg-purple-900 text-purple-200 border-purple-700',
delivered: 'bg-green-900 text-green-200 border-green-700',
cancelled: 'bg-red-900 text-red-200 border-red-700',
};
return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700';
};
if (loading) {
return (
<div className="text-center py-12">
<div className="text-gray-400">Loading orders...</div>
</div>
);
}
return (
<div>
<h1 className="text-3xl font-bold text-white mb-8">My Orders</h1>
{orders.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-400 mb-8">You have no orders yet</p>
<button
onClick={() => navigate('/products')}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Browse Products
</button>
</div>
) : (
<div className="space-y-6">
{orders.map((order) => (
<div
key={order.id}
className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden"
>
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-white">Order #{order.id}</h3>
<p className="text-sm text-gray-400">
{new Date(order.created_at).toLocaleDateString()}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-sm font-medium border ${getStatusColor(
order.status
)}`}
>
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</span>
</div>
<div className="p-4">
{order.items.map((item) => (
<div
key={item.id}
className="flex justify-between items-center py-2 border-b border-gray-700 last:border-b-0"
>
<div>
<p className="text-white font-medium">Product #{item.product_id}</p>
<p className="text-sm text-gray-400">Quantity: {item.quantity}</p>
</div>
<p className="text-white font-bold">
${(item.price * item.quantity).toFixed(2)}
</p>
</div>
))}
</div>
<div className="p-4 bg-gray-750 border-t border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-400">
{order.shipping_address && <span>Ship to: {order.shipping_address}</span>}
</div>
<div className="text-xl">
<span className="text-gray-400">Total:</span>{' '}
<span className="text-white font-bold">${order.total_amount}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -1,66 +0,0 @@
import { useApp } from '../context/AppContext';
import { useProducts } from '../hooks/useProducts';
import { CartItem } from '../types';
export function Products() {
const { products, refetch } = useProducts();
const { addToCart } = useApp();
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-white">Products</h1>
<button
onClick={() => refetch()}
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors"
>
Refresh
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{products.map((product) => (
<div
key={product.id}
className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-blue-500 transition-colors"
>
{product.image_url && (
<img
src={product.image_url}
alt={product.name}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">{product.name}</h3>
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{product.description}</p>
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-blue-400">${product.price}</span>
<span className="text-sm text-gray-400">Stock: {product.stock}</span>
</div>
<button
onClick={() => {
const cartItem: CartItem = {
id: parseInt(product.id!),
name: product.name,
price: product.price,
quantity: 1,
image_url: product.image_url,
};
addToCart(cartItem);
}}
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors"
>
Add to Cart
</button>
</div>
</div>
))}
</div>
{products.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">No products available</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,245 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent';
import useWikiDetail from '../hooks/useWikiDetail';
import Edit2Icon from '../components/icons/Edit2Icon';
import Trash2Icon from '../components/icons/Trash2Icon';
import ChevronRightIcon from '../components/icons/ChevronRightIcon';
export function WikiDetail() {
const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>();
const navigate = useNavigate();
const { wiki, deleteWiki } = useWikiDetail(wikiId || '0');
const handleDelete = async () => {
if (!wiki) return;
if (confirm(`Are you sure you want to delete wiki "${wiki.name}"?`)) {
try {
await deleteWiki();
navigate(`/boards/${boardId}/wikis`);
} catch {
// Error is already handled by hook with toast
}
}
};
if (!wiki) {
return (
<div className="space-y-6">
<WidePageLayout>
<div className="text-center py-12">
<div className="text-gray-400">Loading wiki...</div>
</div>
</WidePageLayout>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/wikis`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Wikis
</Link>
<div className="flex items-center justify-between mt-2">
<div>
<h1 className="text-3xl font-bold text-white">{wiki.name}</h1>
{wiki.slug && <span className="text-gray-400 text-sm">/{wiki.slug}</span>}
</div>
<div className="flex gap-2">
<Link
to={`/boards/${boardId}/wikis/${wiki.id}/edit`}
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
Edit
</Link>
<button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
Delete
</button>
</div>
</div>
</div>
</WidePageLayout>
{/* Content */}
<WidePageLayout>
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<div className="p-6">
{/* Summary */}
{wiki.summary && (
<div className="mb-6 pb-6 border-b border-gray-700">
<h2 className="text-lg font-semibold text-white mb-2">Summary</h2>
<p className="text-gray-300">{wiki.summary}</p>
</div>
)}
{/* Rich Text Content */}
{wiki.content && Array.isArray(wiki.content) && wiki.content.length > 0 && (
<div className="mb-6 pb-6 border-b border-gray-700">
<h2 className="text-lg font-semibold text-white mb-2">Content</h2>
<RichTextContent content={wiki.content} />
</div>
)}
{/* Tags */}
{wiki.tags && wiki.tags.length > 0 && (
<div className="mb-6 pb-6 border-b border-gray-700">
<h2 className="text-lg font-semibold text-white mb-3">Tags</h2>
<div className="flex flex-wrap gap-2">
{wiki.tags.map((tag, index) => (
<span
key={index}
className="px-3 py-1 bg-blue-600 text-white text-sm rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="pt-6 border-t border-gray-700">
<h2 className="text-lg font-semibold text-white mb-3">Metadata</h2>
<div className="space-y-2 text-sm">
{wiki.category && (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Category:</span>
<span className="px-2 py-1 bg-gray-700 text-gray-300 rounded text-sm">
{wiki.category}
</span>
</div>
)}
{wiki.created_by && (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Created By:</span>
<span className="text-gray-300">User #{wiki.created_by}</span>
</div>
)}
{wiki.updated_by && (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Updated By:</span>
<span className="text-gray-300">User #{wiki.updated_by}</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Created:</span>
<span className="text-gray-300">
{wiki.created_at ? new Date(wiki.created_at).toLocaleString() : 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Updated:</span>
<span className="text-gray-300">
{wiki.updated_at ? new Date(wiki.updated_at).toLocaleString() : 'N/A'}
</span>
</div>
</div>
</div>
{/* Linked Entities Section */}
<div className="pt-6 border-t border-gray-700 mt-6">
<h2 className="text-lg font-semibold text-white mb-3">Linked Entities</h2>
{/* Linked Cards */}
{wiki.linked_cards && wiki.linked_cards.length > 0 && (
<div className="mb-6">
<h3 className="text-md font-medium text-gray-300 mb-3">Linked Cards</h3>
<div className="space-y-2">
{wiki.linked_cards.map((card) => (
<Link
key={card.id}
to={`/boards/${boardId}/cards/${card.id}`}
className="block bg-gray-700 hover:bg-gray-600 border border-gray-600 hover:border-gray-500 rounded-lg p-3 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="text-white font-medium">{card.name}</h4>
{card.description && (
<p className="text-gray-400 text-sm mt-1 line-clamp-2">
{card.description}
</p>
)}
</div>
<span className="w-4 h-4 text-gray-400">
<ChevronRightIcon />
</span>
</div>
</Link>
))}
</div>
</div>
)}
{/* Linked Epics */}
{wiki.linked_epics && wiki.linked_epics.length > 0 && (
<div className="mb-6">
<h3 className="text-md font-medium text-gray-300 mb-3">Linked Epics</h3>
<div className="space-y-2">
{wiki.linked_epics.map((epic) => (
<Link
key={epic.id}
to={`/boards/${boardId}/epics/${epic.id}`}
className="block bg-gray-700 hover:bg-gray-600 border border-gray-600 hover:border-gray-500 rounded-lg p-3 transition-colors"
>
<div className="flex items-center gap-3">
{epic.color && (
<div
className="w-4 h-4 rounded-full flex-shrink-0"
style={{ backgroundColor: epic.color }}
/>
)}
<div className="flex-1">
<h4 className="text-white font-medium">{epic.name}</h4>
{epic.description && (
<p className="text-gray-400 text-sm mt-1 line-clamp-2">
{epic.description}
</p>
)}
</div>
{epic.metrics && (
<div className="text-gray-400 text-sm flex-shrink-0">
{epic.metrics.card_count !== undefined && (
<span>{epic.metrics.card_count} cards</span>
)}
</div>
)}
</div>
</Link>
))}
</div>
</div>
)}
{/* No linked entities */}
{(!wiki.linked_cards || wiki.linked_cards.length === 0) &&
(!wiki.linked_epics || wiki.linked_epics.length === 0) && (
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2">🔗</div>
<p>This wiki has no linked entities yet</p>
</div>
)}
</div>
</div>
</div>
</WidePageLayout>
</div>
);
}
export default WikiDetail;

View file

@ -0,0 +1,89 @@
import type { Card } from './kanban';
export interface Epic {
id: number;
name: string;
description: string | null;
content: Record<string, unknown> | null;
color: string | null;
closed: boolean;
pos: number | null;
depth_limit: number | null;
board_id: number;
parent_epic_id: number | null;
completed_list_id: number | null;
date_last_activity: string | null;
created_at: string | null;
updated_at: string | null;
metrics: {
card_count?: number;
completed_cards_count?: number;
} | null;
cards?: Card[];
}
export interface Wiki {
id: number;
name: string;
slug: string | null;
content: Record<string, unknown>;
summary: string | null;
category: string | null;
board_id: number;
created_by: number | null;
updated_by: number | null;
created_at: string | null;
updated_at: string | null;
tags: string[] | null;
linked_cards?: Card[];
linked_epics?: Epic[];
}
export interface CreateEpicRequest {
name: string;
description?: string;
content?: Record<string, unknown>;
color?: string;
pos?: number;
depth_limit?: number;
parent_epic_id?: number | null;
completed_list_id?: number | null;
}
export interface UpdateEpicRequest {
name?: string;
description?: string;
content?: Record<string, unknown>;
color?: string;
closed?: boolean;
pos?: number;
depth_limit?: number;
parent_epic_id?: number | null;
completed_list_id?: number | null;
}
export interface CreateWikiRequest {
name: string;
slug?: string;
content: Record<string, unknown>;
summary?: string;
category?: string;
tags?: string[];
}
export interface UpdateWikiRequest {
name?: string;
slug?: string;
content?: Record<string, unknown>;
summary?: string;
category?: string;
tags?: string[];
}
export interface WikiEntityLink {
wiki_id: number;
entity_type: string;
entity_id: number;
created_at: string | null;
linked_by: number | null;
}

View file

@ -73,6 +73,7 @@ export interface Card {
id_short: number; id_short: number;
board_id: number; board_id: number;
list_id: number; list_id: number;
epic_id: number | null;
date_last_activity: string; date_last_activity: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@ -85,6 +86,13 @@ export interface CardWithDetails extends Card {
labels: Label[]; labels: Label[];
checklists: Checklist[]; checklists: Checklist[];
comments: Comment[]; comments: Comment[];
epic?: EpicBasic;
}
export interface EpicBasic {
id: number;
name: string;
color: string | null;
} }
export interface CardCreate { export interface CardCreate {

View file

@ -1,24 +0,0 @@
export interface OrderItem {
id: string;
product_id: string;
quantity: number;
price: number;
}
export interface OrderData {
id: string;
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
total_amount: number;
created_at: string;
shipping_address?: string;
items: OrderItem[];
}
export interface Order {
id: number;
created_at: string;
status: string;
total_amount: number;
shipping_address: string;
items: OrderItem[];
}

View file

@ -1,26 +0,0 @@
export interface Product {
id: string;
name: string;
description: string;
price: number;
stock: number;
image_url?: string;
}
export interface ProductData {
id?: string;
name: string;
description: string;
price: number;
stock: number;
image_url?: string;
}
export interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
image_url?: string;
[key: string]: any;
}

View file

@ -21,7 +21,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
/* Path Aliases */ /* Path Aliases */
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }