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:
commit
65d0a32cb5
83 changed files with 7758 additions and 1781 deletions
2
Makefile
2
Makefile
|
|
@ -136,7 +136,7 @@ format-frontend: ## Format frontend code only
|
|||
@echo "Formatting frontend..."
|
||||
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
|
||||
|
||||
shell: ## Open Flask shell
|
||||
|
|
|
|||
|
|
@ -39,6 +39,18 @@ class Config:
|
|||
"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):
|
||||
"""Development configuration"""
|
||||
|
|
|
|||
21
backend/app/decorators/__init__.py
Normal file
21
backend/app/decorators/__init__.py
Normal 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",
|
||||
]
|
||||
|
|
@ -3,8 +3,7 @@ from functools import wraps
|
|||
from flask import abort, g
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
|
||||
from app.models import (Board, Card, CheckItem, Checklist, Comment,
|
||||
FileAttachment, List)
|
||||
from app.models import Board, Card, Comment, FileAttachment
|
||||
|
||||
|
||||
def get_current_user_id():
|
||||
|
|
@ -15,176 +14,6 @@ def get_current_user_id():
|
|||
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):
|
||||
"""
|
||||
Loads a FileAttachment ensuring the user can access it.
|
||||
177
backend/app/decorators/owned.py
Normal file
177
backend/app/decorators/owned.py
Normal 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
|
||||
38
backend/app/decorators/schema_validations.py
Normal file
38
backend/app/decorators/schema_validations.py
Normal 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
|
||||
|
|
@ -4,18 +4,15 @@ from app.models.card_label import CardLabel
|
|||
from app.models.check_item import CheckItem
|
||||
from app.models.checklist import Checklist
|
||||
from app.models.comment import Comment
|
||||
from app.models.epic import Epic
|
||||
from app.models.file_attachment import FileAttachment
|
||||
from app.models.label import Label
|
||||
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.wiki import Wiki, wiki_entity_links
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Product",
|
||||
"Order",
|
||||
"OrderItem",
|
||||
"Board",
|
||||
"List",
|
||||
"Card",
|
||||
|
|
@ -25,4 +22,7 @@ __all__ = [
|
|||
"CheckItem",
|
||||
"Comment",
|
||||
"FileAttachment",
|
||||
"Epic",
|
||||
"Wiki",
|
||||
"wiki_entity_links",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class Card(db.Model):
|
|||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
epic_id = db.Column(
|
||||
db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
date_last_activity = db.Column(db.DateTime)
|
||||
|
|
@ -79,6 +82,7 @@ class Card(db.Model):
|
|||
"id_short": self.id_short,
|
||||
"board_id": self.board_id,
|
||||
"list_id": self.list_id,
|
||||
"epic_id": self.epic_id,
|
||||
"date_last_activity": self.date_last_activity.isoformat()
|
||||
if self.date_last_activity
|
||||
else None,
|
||||
|
|
@ -91,3 +95,130 @@ class Card(db.Model):
|
|||
|
||||
def __repr__(self):
|
||||
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)
|
||||
|
|
|
|||
87
backend/app/models/epic.py
Normal file
87
backend/app/models/epic.py
Normal 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}>"
|
||||
|
|
@ -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}>"
|
||||
|
|
@ -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}>"
|
||||
|
|
@ -26,7 +26,6 @@ class User(db.Model):
|
|||
)
|
||||
|
||||
# Relationships
|
||||
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
|
||||
boards = db.relationship(
|
||||
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
|
||||
)
|
||||
|
|
|
|||
111
backend/app/models/wiki.py
Normal file
111
backend/app/models/wiki.py
Normal 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}>"
|
||||
|
|
@ -1,12 +1,9 @@
|
|||
from flask import Blueprint, jsonify, request
|
||||
from flask_jwt_extended import (create_access_token, create_refresh_token,
|
||||
get_jwt_identity, jwt_required)
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app import db
|
||||
from app.celery import celery
|
||||
from app.models import Order, OrderItem, Product, User
|
||||
from app.schemas import ProductCreateRequest, ProductResponse
|
||||
from app.models import User
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
|
|
@ -79,333 +76,3 @@ def get_current_user():
|
|||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ kanban_bp = Blueprint("kanban", __name__)
|
|||
# Import all route modules to register their routes to this blueprint
|
||||
|
||||
# fmt: off
|
||||
from . import (boards, cards, checklists, comments, files, # noqa: F401 E402
|
||||
labels, lists)
|
||||
from . import (boards, cards, checklists, comments, epics, # noqa: F401 E402
|
||||
files, labels, lists, wikis)
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
|
|||
from flask_pydantic import validate
|
||||
|
||||
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.schemas import (BoardCreateRequest, BoardResponse,
|
||||
BoardWithDetailsResponse)
|
||||
|
|
@ -64,6 +65,12 @@ def get_board(board_id, board):
|
|||
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)
|
||||
|
||||
lists_data.append({**lst.to_dict(), "cards": cards_data})
|
||||
|
|
|
|||
|
|
@ -77,6 +77,12 @@ def get_card(card_id, card):
|
|||
comment_dict["user"] = user.to_dict() if user else None
|
||||
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)
|
||||
return response.model_dump(), 200
|
||||
|
||||
|
|
|
|||
170
backend/app/routes/kanban/epics.py
Normal file
170
backend/app/routes/kanban/epics.py
Normal 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
|
||||
232
backend/app/routes/kanban/wikis.py
Normal file
232
backend/app/routes/kanban/wikis.py
Normal 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
|
||||
|
|
@ -7,18 +7,19 @@ from app.schemas.check_item import CheckItemCreateRequest, CheckItemResponse
|
|||
from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse
|
||||
from app.schemas.comment import (CommentCreateRequest, CommentResponse,
|
||||
CommentWithUserResponse)
|
||||
from app.schemas.epic import (EpicCreateRequest, EpicResponse,
|
||||
EpicUpdateRequest, EpicWithCardsResponse)
|
||||
from app.schemas.file_attachment import (FileAttachmentWithUrlResponse,
|
||||
FileDeleteResponse, FileUploadRequest,
|
||||
MultipleFilesResponse,
|
||||
ValidationError)
|
||||
from app.schemas.label import LabelCreateRequest, LabelResponse
|
||||
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__ = [
|
||||
# Product schemas
|
||||
"ProductCreateRequest",
|
||||
"ProductResponse",
|
||||
# Board schemas
|
||||
"BoardCreateRequest",
|
||||
"BoardResponse",
|
||||
|
|
@ -50,4 +51,15 @@ __all__ = [
|
|||
"BoardWithDetailsResponse",
|
||||
"CardWithDetailsResponse",
|
||||
"CommentWithUserResponse",
|
||||
# Epic schemas
|
||||
"EpicCreateRequest",
|
||||
"EpicResponse",
|
||||
"EpicUpdateRequest",
|
||||
"EpicWithCardsResponse",
|
||||
# Wiki schemas
|
||||
"WikiCreateRequest",
|
||||
"WikiResponse",
|
||||
"WikiUpdateRequest",
|
||||
"WikiEntityLink",
|
||||
"WikiLinkRequest",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ class CardResponse(BaseModel):
|
|||
"pos": 65535.0,
|
||||
"list_id": 1,
|
||||
"board_id": 1,
|
||||
"epic_id": 1,
|
||||
"due": "2024-12-31T23:59:59",
|
||||
"due_complete": False,
|
||||
"badges": {"votes": 0},
|
||||
|
|
@ -64,6 +65,7 @@ class CardResponse(BaseModel):
|
|||
pos: float
|
||||
list_id: int
|
||||
board_id: int
|
||||
epic_id: Optional[int]
|
||||
due: Optional[datetime] = None
|
||||
due_complete: bool
|
||||
badges: Optional[Dict[str, Any]] = None
|
||||
|
|
@ -115,3 +117,4 @@ class CardWithDetailsResponse(BaseModel):
|
|||
labels: 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)
|
||||
epic: Optional[Dict[str, Any]] = None
|
||||
|
|
|
|||
139
backend/app/schemas/epic.py
Normal file
139
backend/app/schemas/epic.py
Normal 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)
|
||||
|
|
@ -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
174
backend/app/schemas/wiki.py
Normal 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
|
||||
|
|
@ -54,6 +54,7 @@ class StorageExtension:
|
|||
access_key=app.config["MINIO_ACCESS_KEY"],
|
||||
secret_key=app.config["MINIO_SECRET_KEY"],
|
||||
secure=app.config["MINIO_USE_SSL"],
|
||||
region=app.config["MINIO_REGION"],
|
||||
)
|
||||
|
||||
# Validate connection by listing buckets
|
||||
|
|
@ -93,17 +94,11 @@ class StorageExtension:
|
|||
]
|
||||
|
||||
for bucket_name in buckets:
|
||||
try:
|
||||
if not self.client.bucket_exists(bucket_name):
|
||||
self.client.make_bucket(bucket_name)
|
||||
app.logger.info(f"Created MinIO bucket: {bucket_name}")
|
||||
if self.client.bucket_exists(bucket_name):
|
||||
app.logger.debug(f"MinIO bucket exists: {bucket_name}")
|
||||
else:
|
||||
app.logger.debug(f"MinIO bucket already exists: {bucket_name}")
|
||||
except S3Error as e:
|
||||
app.logger.error(f"Failed to ensure bucket {bucket_name}: {e}")
|
||||
raise RuntimeError(
|
||||
f"Failed to create MinIO bucket '{bucket_name}': {e}"
|
||||
) from e
|
||||
app.logger.error(f"MinIO bucket does not exists: {bucket_name}")
|
||||
raise RuntimeError(f"Failed to create MinIO bucket '{bucket_name}'")
|
||||
|
||||
def get_client(self) -> Minio:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"""Pytest configuration and fixtures"""
|
||||
import logging
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
|
|
@ -7,8 +8,7 @@ from faker import Faker
|
|||
from PIL import Image
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import (Board, Card, Comment, List, Order, OrderItem, Product,
|
||||
User)
|
||||
from app.models import Board, Card, Comment, List, User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
fake = Faker()
|
||||
|
|
@ -116,7 +116,7 @@ def regular_user(db_session):
|
|||
"""Create a regular user for testing"""
|
||||
user = User(
|
||||
email=fake.email(),
|
||||
username=fake.user_name(),
|
||||
username=f"{fake.user_name()}_{int(time.time() * 1000)}",
|
||||
first_name=fake.first_name(),
|
||||
last_name=fake.last_name(),
|
||||
is_admin=False,
|
||||
|
|
@ -145,39 +145,6 @@ def inactive_user(db_session):
|
|||
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
|
||||
def auth_headers(client, regular_user):
|
||||
"""Get authentication headers for a regular user"""
|
||||
|
|
@ -203,33 +170,6 @@ def admin_headers(client, admin_user):
|
|||
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 ============
|
||||
|
||||
|
||||
|
|
|
|||
702
backend/tests/routes/test_epics.py
Normal file
702
backend/tests/routes/test_epics.py
Normal 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
|
||||
882
backend/tests/routes/test_wikis.py
Normal file
882
backend/tests/routes/test_wikis.py
Normal 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
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
"""Test models"""
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import Order, OrderItem, Product, User
|
||||
from app.models import User
|
||||
|
||||
|
||||
class TestUserModel:
|
||||
|
|
@ -69,132 +68,3 @@ class TestUserModel:
|
|||
db_session.commit()
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -112,234 +112,3 @@ class TestAuthRoutes:
|
|||
"""Test getting current user without authentication"""
|
||||
response = client.get("/api/users/me")
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
338
docs/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md
Normal file
338
docs/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md
Normal 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
|
||||
238
docs/EPIC_WIKI_MODELS_SUMMARY.md
Normal file
238
docs/EPIC_WIKI_MODELS_SUMMARY.md
Normal 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
|
||||
|
|
@ -372,13 +372,13 @@ useEffect(() => {
|
|||
```
|
||||
|
||||
### 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
|
||||
- Create reusable SVG icon components when needed
|
||||
- SVGs should be defined as functional components
|
||||
- Each icon should be a separate file with a default export
|
||||
- Import icons from the icons folder
|
||||
|
||||
```jsx
|
||||
// ✅ CORRECT - Inline SVG as a component
|
||||
// ✅ CORRECT - Icon component in frontend/src/components/icons/TrashIcon.tsx
|
||||
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">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
|
|
@ -386,6 +386,11 @@ const TrashIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
export default TrashIcon;
|
||||
|
||||
// ✅ CORRECT - Using icon component
|
||||
import TrashIcon from "../components/icons/TrashIcon";
|
||||
|
||||
function DeleteButton() {
|
||||
return (
|
||||
<button className="flex items-center gap-2">
|
||||
|
|
@ -418,14 +423,31 @@ function DeleteButton() {
|
|||
</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
|
||||
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
|
||||
4. **Consistency**: All icons follow to same pattern and styling
|
||||
5. **TypeScript support**: Full type safety without any issues
|
||||
4. **Consistency**: All icons follow the same pattern and are organized in one place
|
||||
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
|
||||
|
||||
|
|
@ -802,7 +824,7 @@ export function useNewContext() {
|
|||
❌ **NEVER** hardcode API URLs
|
||||
❌ **NEVER** skip PropTypes validation
|
||||
❌ **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
|
||||
|
||||
|
|
|
|||
109
frontend/package-lock.json
generated
109
frontend/package-lock.json
generated
|
|
@ -17,6 +17,9 @@
|
|||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"slate": "^0.123.0",
|
||||
"slate-history": "^0.113.1",
|
||||
"slate-react": "^0.123.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1055,6 +1058,11 @@
|
|||
"@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": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -2776,6 +2784,11 @@
|
|||
"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": {
|
||||
"version": "0.0.1",
|
||||
"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_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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
|
|
@ -4496,6 +4521,11 @@
|
|||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
||||
|
|
@ -4554,6 +4584,14 @@
|
|||
"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": {
|
||||
"version": "1.0.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
|
@ -6220,6 +6263,14 @@
|
|||
"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": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
|
|
@ -6400,6 +6451,59 @@
|
|||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -6744,6 +6848,11 @@
|
|||
"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": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@
|
|||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"slate": "^0.123.0",
|
||||
"slate-history": "^0.113.1",
|
||||
"slate-react": "^0.123.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,15 @@ import { BoardCreate } from './pages/BoardCreate';
|
|||
import { BoardEdit } from './pages/BoardEdit';
|
||||
import { BoardDetail } from './pages/BoardDetail';
|
||||
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 { BoardDetailLayout } from './components/BoardDetailLayout';
|
||||
|
||||
const App = () => {
|
||||
const { token } = useApp();
|
||||
|
|
@ -73,7 +81,9 @@ const App = () => {
|
|||
path="/boards/:id/edit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BoardDetailLayout>
|
||||
<BoardEdit />
|
||||
</BoardDetailLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
|
@ -81,7 +91,9 @@ const App = () => {
|
|||
path="/boards/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BoardDetailLayout>
|
||||
<BoardDetail />
|
||||
</BoardDetailLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
|
@ -89,7 +101,79 @@ const App = () => {
|
|||
path="/boards/:id/epics"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BoardDetailLayout>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
|
@ -97,7 +181,9 @@ const App = () => {
|
|||
path="/boards/:id/cards/:cardId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BoardDetailLayout>
|
||||
<CardDetail />
|
||||
</BoardDetailLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
18
frontend/src/components/BoardDetailLayout.tsx
Normal file
18
frontend/src/components/BoardDetailLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -9,32 +9,34 @@ export function BoardSidebar({ boardId }: BoardSidebarProps) {
|
|||
|
||||
const menuItems = [
|
||||
{ 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: 'documents', label: 'Documents', icon: '📄', path: `/boards/${boardId}/documents` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-gray-800 rounded-lg p-4 border border-gray-700 h-fit sticky top-4">
|
||||
<h3 className="text-white font-bold text-lg mb-4">Board Menu</h3>
|
||||
<nav className="space-y-2">
|
||||
<div className="fixed right-0 top-1/2 -translate-y-1/2 z-40">
|
||||
<div className="flex flex-col gap-2">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
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
|
||||
? '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>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
159
frontend/src/components/CardEpics.tsx
Normal file
159
frontend/src/components/CardEpics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
|
|||
<div className="flex justify-between items-start mb-4">
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
{card.name}
|
||||
|
|
@ -24,6 +25,24 @@ export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
|
|||
</button>
|
||||
</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 */}
|
||||
{card.labels && card.labels.length > 0 && (
|
||||
<div className="mb-4">
|
||||
|
|
|
|||
37
frontend/src/components/ELEMENT_TAGS.ts
Normal file
37
frontend/src/components/ELEMENT_TAGS.ts
Normal 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 };
|
||||
34
frontend/src/components/RichTextContent.tsx
Normal file
34
frontend/src/components/RichTextContent.tsx
Normal 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;
|
||||
130
frontend/src/components/RichTextEditor.tsx
Normal file
130
frontend/src/components/RichTextEditor.tsx
Normal 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;
|
||||
196
frontend/src/components/custom-types.d.ts
vendored
Normal file
196
frontend/src/components/custom-types.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
17
frontend/src/components/icons/CheckIcon.tsx
Normal file
17
frontend/src/components/icons/CheckIcon.tsx
Normal 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;
|
||||
17
frontend/src/components/icons/ChevronRightIcon.tsx
Normal file
17
frontend/src/components/icons/ChevronRightIcon.tsx
Normal 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;
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
const Edit2Icon = () => (
|
||||
interface Edit2IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Edit2Icon = ({ className = '' }: Edit2IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
|
|
@ -9,6 +13,7 @@ const Edit2Icon = () => (
|
|||
strokeWidth="2"
|
||||
strokeLinecap="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>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
const PlusIcon = () => (
|
||||
interface PlusIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PlusIcon = ({ className = '' }: PlusIconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
|
|
@ -9,6 +13,7 @@ const PlusIcon = () => (
|
|||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
const Trash2Icon = () => (
|
||||
interface Trash2IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Trash2Icon = ({ className = '' }: Trash2IconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
|
|
@ -9,6 +13,7 @@ const Trash2Icon = () => (
|
|||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
18
frontend/src/components/icons/UnlinkIcon.tsx
Normal file
18
frontend/src/components/icons/UnlinkIcon.tsx
Normal 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;
|
||||
16
frontend/src/components/isImageUrl.ts
Normal file
16
frontend/src/components/isImageUrl.ts
Normal 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;
|
||||
|
|
@ -43,6 +43,10 @@ export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
|
|||
const labels = (card as any).labels || [];
|
||||
const hasLabels = labels.length > 0;
|
||||
|
||||
// Get epic
|
||||
const epic = (card as any).epic;
|
||||
const hasEpic = epic !== null && epic !== undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{/* 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 */}
|
||||
{hasLabels && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ export function KanbanColumn({
|
|||
};
|
||||
|
||||
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="mb-4">
|
||||
<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 px-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{/* Drag Handle Icon */}
|
||||
|
|
@ -133,7 +133,7 @@ export function KanbanColumn({
|
|||
>
|
||||
<div
|
||||
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) => (
|
||||
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />
|
||||
|
|
|
|||
630
frontend/src/components/slate-editor-components/index.tsx
Normal file
630
frontend/src/components/slate-editor-components/index.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import axios from 'axios';
|
||||
import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types';
|
||||
import { RegisterData, UserData, AuthResponse } from '../types';
|
||||
import {
|
||||
Board,
|
||||
BoardWithDetails,
|
||||
|
|
@ -69,44 +69,6 @@ export function useApi() {
|
|||
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
|
||||
getBoards: async (): Promise<Board[]> => {
|
||||
const response = await api.get<Board[]>('/boards');
|
||||
|
|
@ -299,6 +261,55 @@ export function useApi() {
|
|||
});
|
||||
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}`);
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
|
|
|||
100
frontend/src/hooks/useCardEpics.ts
Normal file
100
frontend/src/hooks/useCardEpics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
105
frontend/src/hooks/useEpicDetail.ts
Normal file
105
frontend/src/hooks/useEpicDetail.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApi } from './useApi';
|
||||
import { useLoader } from '../context/loaders/useLoader';
|
||||
import { useToast } from '../context/toasts/useToast';
|
||||
import type { 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;
|
||||
144
frontend/src/hooks/useEpics.ts
Normal file
144
frontend/src/hooks/useEpics.ts
Normal 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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
105
frontend/src/hooks/useWikiDetail.ts
Normal file
105
frontend/src/hooks/useWikiDetail.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApi } from './useApi';
|
||||
import { useLoader } from '../context/loaders/useLoader';
|
||||
import { useToast } from '../context/toasts/useToast';
|
||||
import type { 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;
|
||||
144
frontend/src/hooks/useWikis.ts
Normal file
144
frontend/src/hooks/useWikis.ts
Normal 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;
|
||||
|
|
@ -1,3 +1,28 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useListMutations } from '../hooks/useListMutations';
|
|||
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
|
||||
import { CreateListModal } from '../components/kanban/CreateListModal';
|
||||
import { CardPreviewModal } from '../components/CardPreviewModal';
|
||||
import { BoardSidebar } from '../components/BoardSidebar';
|
||||
import { useModal } from '../context/modals/useModal';
|
||||
import {
|
||||
DndContext,
|
||||
|
|
@ -244,7 +243,7 @@ export function BoardDetail() {
|
|||
</div>
|
||||
</WidePageLayout>
|
||||
|
||||
<div className="px-4 sm:px-6 lg:px-8 ">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-start gap-6">
|
||||
<div className="flex-1">
|
||||
<DndContext
|
||||
|
|
@ -289,8 +288,6 @@ export function BoardDetail() {
|
|||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<BoardSidebar boardId={id || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 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() {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<WidePageLayout>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link
|
||||
to={`/boards/${id}`}
|
||||
|
|
@ -15,21 +34,150 @@ export function BoardEpics() {
|
|||
← Back to Board
|
||||
</Link>
|
||||
<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>
|
||||
</WidePageLayout>
|
||||
|
||||
<WidePageLayout>
|
||||
{epics.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">Epics Coming Soon</h2>
|
||||
<p className="text-gray-400">
|
||||
This page will allow you to create and manage epics for your board.
|
||||
<h2 className="text-xl font-bold text-white mb-2">No Epics Yet</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Create your first epic to start organizing your work.
|
||||
</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 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>
|
||||
</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;
|
||||
|
|
|
|||
158
frontend/src/pages/BoardWikis.tsx
Normal file
158
frontend/src/pages/BoardWikis.tsx
Normal 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;
|
||||
|
|
@ -9,6 +9,7 @@ import { CardSidebar } from '../components/CardSidebar';
|
|||
import { CardComments } from '../components/CardComments';
|
||||
import { CardChecklists } from '../components/CardChecklists';
|
||||
import { CardLabels } from '../components/CardLabels';
|
||||
import { CardEpics } from '../components/CardEpics';
|
||||
import { CardAttachments } from '../components/CardAttachments';
|
||||
import { EditCardModal } from '../components/EditCardModal';
|
||||
import { DeleteCardModal } from '../components/DeleteCardModal';
|
||||
|
|
@ -154,6 +155,14 @@ export function CardDetail() {
|
|||
refetchCard={fetchCard}
|
||||
/>
|
||||
|
||||
{/* Epic Section */}
|
||||
<CardEpics
|
||||
cardEpics={card.epic ? [card.epic] : []}
|
||||
boardId={parseInt(boardId || '0')}
|
||||
cardId={parseInt(cardId || '0')}
|
||||
refetchCard={fetchCard}
|
||||
/>
|
||||
|
||||
{/* Checklists Section */}
|
||||
<CardChecklists
|
||||
checklists={card.checklists || []}
|
||||
|
|
|
|||
217
frontend/src/pages/CreateEpic.tsx
Normal file
217
frontend/src/pages/CreateEpic.tsx
Normal 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;
|
||||
184
frontend/src/pages/CreateWiki.tsx
Normal file
184
frontend/src/pages/CreateWiki.tsx
Normal 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;
|
||||
246
frontend/src/pages/EditEpic.tsx
Normal file
246
frontend/src/pages/EditEpic.tsx
Normal 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;
|
||||
230
frontend/src/pages/EditWiki.tsx
Normal file
230
frontend/src/pages/EditWiki.tsx
Normal 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;
|
||||
237
frontend/src/pages/EpicDetail.tsx
Normal file
237
frontend/src/pages/EpicDetail.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
245
frontend/src/pages/WikiDetail.tsx
Normal file
245
frontend/src/pages/WikiDetail.tsx
Normal 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;
|
||||
89
frontend/src/types/epic.ts
Normal file
89
frontend/src/types/epic.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ export interface Card {
|
|||
id_short: number;
|
||||
board_id: number;
|
||||
list_id: number;
|
||||
epic_id: number | null;
|
||||
date_last_activity: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -85,6 +86,13 @@ export interface CardWithDetails extends Card {
|
|||
labels: Label[];
|
||||
checklists: Checklist[];
|
||||
comments: Comment[];
|
||||
epic?: EpicBasic;
|
||||
}
|
||||
|
||||
export interface EpicBasic {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export interface CardCreate {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue