refactor active record usage
This commit is contained in:
parent
8e138689cf
commit
5b95262681
21 changed files with 319 additions and 158 deletions
2
.github/workflows/backend.yml
vendored
2
.github/workflows/backend.yml
vendored
|
|
@ -69,7 +69,6 @@ jobs:
|
||||||
print(f'Created database: {new_db}')
|
print(f'Created database: {new_db}')
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|
||||||
- name: Debug cache
|
- name: Debug cache
|
||||||
run: |
|
run: |
|
||||||
echo "Listing PIP cache files:"
|
echo "Listing PIP cache files:"
|
||||||
|
|
@ -83,7 +82,6 @@ jobs:
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
UNIQUE_DB: test_db_${{ github.run_id }}
|
|
||||||
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
|
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
|
||||||
DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
|
DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }}
|
||||||
SECRET_KEY: test-secret-key
|
SECRET_KEY: test-secret-key
|
||||||
|
|
|
||||||
|
|
@ -29,16 +29,17 @@ def load_file_accessible(f):
|
||||||
user_id = get_current_user_id()
|
user_id = get_current_user_id()
|
||||||
file_id = kwargs.get("file_id")
|
file_id = kwargs.get("file_id")
|
||||||
|
|
||||||
# Try to find file uploaded by user
|
# Try to find active file uploaded by user
|
||||||
attachment = FileAttachment.query.filter_by(
|
attachment = (
|
||||||
id=file_id, uploaded_by=user_id
|
FileAttachment.active().filter_by(id=file_id, uploaded_by=user_id).first()
|
||||||
).first()
|
)
|
||||||
|
|
||||||
# If not found, check if attached to a Card that belongs to user's board
|
# If not found, check if attached to a Card that belongs to user's board
|
||||||
if not attachment:
|
if not attachment:
|
||||||
# For Card attachments
|
# For Card attachments (only active files)
|
||||||
card_attachment = (
|
card_attachment = (
|
||||||
FileAttachment.query.join(
|
FileAttachment.active()
|
||||||
|
.join(
|
||||||
Card,
|
Card,
|
||||||
(FileAttachment.attachable_type == "Card")
|
(FileAttachment.attachable_type == "Card")
|
||||||
& (FileAttachment.attachable_id == Card.id),
|
& (FileAttachment.attachable_id == Card.id),
|
||||||
|
|
@ -56,9 +57,10 @@ def load_file_accessible(f):
|
||||||
# If still not found, check if attached
|
# If still not found, check if attached
|
||||||
# to a Comment that belongs to user's board
|
# to a Comment that belongs to user's board
|
||||||
if not attachment:
|
if not attachment:
|
||||||
# For Comment attachments
|
# For Comment attachments (only active files)
|
||||||
comment_attachment = (
|
comment_attachment = (
|
||||||
FileAttachment.query.join(
|
FileAttachment.active()
|
||||||
|
.join(
|
||||||
Comment,
|
Comment,
|
||||||
(FileAttachment.attachable_type == "Comment")
|
(FileAttachment.attachable_type == "Comment")
|
||||||
& (FileAttachment.attachable_id == Comment.id),
|
& (FileAttachment.attachable_id == Comment.id),
|
||||||
|
|
@ -98,10 +100,10 @@ def load_file_accessible_by_uuid(f):
|
||||||
user_id = get_current_user_id()
|
user_id = get_current_user_id()
|
||||||
file_uuid = kwargs.get("file_uuid")
|
file_uuid = kwargs.get("file_uuid")
|
||||||
|
|
||||||
# Try to find file uploaded by user (only active files)
|
# Try to find active file uploaded by user
|
||||||
attachment = (
|
attachment = (
|
||||||
FileAttachment.query.filter_by(uuid=file_uuid, uploaded_by=user_id)
|
FileAttachment.active()
|
||||||
.filter(FileAttachment.status == "active")
|
.filter_by(uuid=file_uuid, uploaded_by=user_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -109,7 +111,8 @@ def load_file_accessible_by_uuid(f):
|
||||||
if not attachment:
|
if not attachment:
|
||||||
# For Card attachments (only active files)
|
# For Card attachments (only active files)
|
||||||
card_attachment = (
|
card_attachment = (
|
||||||
FileAttachment.query.join(
|
FileAttachment.active()
|
||||||
|
.join(
|
||||||
Card,
|
Card,
|
||||||
(FileAttachment.attachable_type == "Card")
|
(FileAttachment.attachable_type == "Card")
|
||||||
& (FileAttachment.attachable_id == Card.id),
|
& (FileAttachment.attachable_id == Card.id),
|
||||||
|
|
@ -118,7 +121,6 @@ def load_file_accessible_by_uuid(f):
|
||||||
.filter(
|
.filter(
|
||||||
FileAttachment.uuid == file_uuid,
|
FileAttachment.uuid == file_uuid,
|
||||||
Board.user_id == user_id,
|
Board.user_id == user_id,
|
||||||
FileAttachment.status == "active",
|
|
||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
@ -130,7 +132,8 @@ def load_file_accessible_by_uuid(f):
|
||||||
if not attachment:
|
if not attachment:
|
||||||
# For Comment attachments (only active files)
|
# For Comment attachments (only active files)
|
||||||
comment_attachment = (
|
comment_attachment = (
|
||||||
FileAttachment.query.join(
|
FileAttachment.active()
|
||||||
|
.join(
|
||||||
Comment,
|
Comment,
|
||||||
(FileAttachment.attachable_type == "Comment")
|
(FileAttachment.attachable_type == "Comment")
|
||||||
& (FileAttachment.attachable_id == Comment.id),
|
& (FileAttachment.attachable_id == Comment.id),
|
||||||
|
|
@ -140,7 +143,6 @@ def load_file_accessible_by_uuid(f):
|
||||||
.filter(
|
.filter(
|
||||||
FileAttachment.uuid == file_uuid,
|
FileAttachment.uuid == file_uuid,
|
||||||
Board.user_id == user_id,
|
Board.user_id == user_id,
|
||||||
FileAttachment.status == "active",
|
|
||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ def load_board_owned(f):
|
||||||
board_id = kwargs.get("board_id")
|
board_id = kwargs.get("board_id")
|
||||||
|
|
||||||
# SECURE QUERY: Filter by ID *and* User ID in the DB
|
# SECURE QUERY: Filter by ID *and* User ID in the DB
|
||||||
board = Board.query.filter_by(id=board_id, user_id=user_id).first()
|
board = Board.active().filter_by(id=board_id, user_id=user_id).first()
|
||||||
|
|
||||||
if not board:
|
if not board:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
@ -45,10 +45,9 @@ def load_card_owned(f):
|
||||||
|
|
||||||
# Join Board to check ownership and filter soft-deleted cards
|
# Join Board to check ownership and filter soft-deleted cards
|
||||||
card = (
|
card = (
|
||||||
Card.query.join(Board)
|
Card.active()
|
||||||
.filter(
|
.join(Board)
|
||||||
Card.id == card_id, Board.user_id == user_id, Card.deleted_at.is_(None)
|
.filter(Card.id == card_id, Board.user_id == user_id)
|
||||||
)
|
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -70,7 +69,8 @@ def load_list_owned(f):
|
||||||
list_id = kwargs.get("list_id")
|
list_id = kwargs.get("list_id")
|
||||||
|
|
||||||
lst = (
|
lst = (
|
||||||
List.query.join(Board)
|
List.active()
|
||||||
|
.join(Board)
|
||||||
.filter(List.id == list_id, Board.user_id == user_id)
|
.filter(List.id == list_id, Board.user_id == user_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
@ -93,7 +93,8 @@ def load_checklist_owned(f):
|
||||||
checklist_id = kwargs.get("checklist_id")
|
checklist_id = kwargs.get("checklist_id")
|
||||||
|
|
||||||
checklist = (
|
checklist = (
|
||||||
Checklist.query.join(Card)
|
Checklist.active()
|
||||||
|
.join(Card)
|
||||||
.join(Board)
|
.join(Board)
|
||||||
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
|
.filter(Checklist.id == checklist_id, Board.user_id == user_id)
|
||||||
.first()
|
.first()
|
||||||
|
|
@ -117,7 +118,8 @@ def load_check_item_owned(f):
|
||||||
item_id = kwargs.get("item_id")
|
item_id = kwargs.get("item_id")
|
||||||
|
|
||||||
check_item = (
|
check_item = (
|
||||||
CheckItem.query.join(Checklist)
|
CheckItem.active()
|
||||||
|
.join(Checklist)
|
||||||
.join(Card)
|
.join(Card)
|
||||||
.join(Board)
|
.join(Board)
|
||||||
.filter(CheckItem.id == item_id, Board.user_id == user_id)
|
.filter(CheckItem.id == item_id, Board.user_id == user_id)
|
||||||
|
|
@ -144,7 +146,7 @@ def load_comment_owned(f):
|
||||||
user_id = get_current_user_id()
|
user_id = get_current_user_id()
|
||||||
comment_id = kwargs.get("comment_id")
|
comment_id = kwargs.get("comment_id")
|
||||||
|
|
||||||
comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first()
|
comment = Comment.active().filter_by(id=comment_id, user_id=user_id).first()
|
||||||
|
|
||||||
if not comment:
|
if not comment:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
@ -167,9 +169,9 @@ def load_file_owned(f):
|
||||||
file_id = kwargs.get("file_id")
|
file_id = kwargs.get("file_id")
|
||||||
|
|
||||||
# Filter by ID and user ID
|
# Filter by ID and user ID
|
||||||
attachment = FileAttachment.query.filter_by(
|
attachment = (
|
||||||
id=file_id, uploaded_by=user_id
|
FileAttachment.active().filter_by(id=file_id, uploaded_by=user_id).first()
|
||||||
).first()
|
)
|
||||||
|
|
||||||
if not attachment:
|
if not attachment:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@ class SoftDeleteMixin:
|
||||||
# Restore
|
# Restore
|
||||||
instance.restore()
|
instance.restore()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
For relationship filtering:
|
||||||
|
class Parent(db.Model, SoftDeleteMixin):
|
||||||
|
children = db.relationship(
|
||||||
|
"Child",
|
||||||
|
primaryjoin="and_(Parent.id == Child.parent_id, "
|
||||||
|
"Child.status == 'active')",
|
||||||
|
...
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
STATUS_ACTIVE = "active"
|
STATUS_ACTIVE = "active"
|
||||||
|
|
@ -48,6 +57,11 @@ class SoftDeleteMixin:
|
||||||
"""
|
"""
|
||||||
return cls.query.filter(cls.status == cls.STATUS_ACTIVE)
|
return cls.query.filter(cls.status == cls.STATUS_ACTIVE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""Check if this record is active (not soft-deleted)."""
|
||||||
|
return self.status == self.STATUS_ACTIVE
|
||||||
|
|
||||||
def soft_delete(self):
|
def soft_delete(self):
|
||||||
"""Mark this record as deleted and cascade to child relationships.
|
"""Mark this record as deleted and cascade to child relationships.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,15 +42,27 @@ class Board(db.Model, SoftDeleteMixin):
|
||||||
label_names = db.Column(JSONB) # label color mappings
|
label_names = db.Column(JSONB) # label color mappings
|
||||||
limits = db.Column(JSONB) # various limits
|
limits = db.Column(JSONB) # various limits
|
||||||
|
|
||||||
# Relationships
|
# Relationships - only active records
|
||||||
lists = db.relationship(
|
lists = db.relationship(
|
||||||
"List", backref="board", cascade="all, delete-orphan", lazy="dynamic"
|
"List",
|
||||||
|
backref="board",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Board.id == List.board_id, List.status == 'active')",
|
||||||
)
|
)
|
||||||
cards = db.relationship(
|
cards = db.relationship(
|
||||||
"Card", backref="board", cascade="all, delete-orphan", lazy="dynamic"
|
"Card",
|
||||||
|
backref="board",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Board.id == Card.board_id, Card.status == 'active')",
|
||||||
)
|
)
|
||||||
labels = db.relationship(
|
labels = db.relationship(
|
||||||
"Label", backref="board", cascade="all, delete-orphan", lazy="dynamic"
|
"Label",
|
||||||
|
backref="board",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Board.id == Label.board_id, Label.status == 'active')",
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
|
|
||||||
|
|
@ -51,32 +51,47 @@ class Card(db.Model, SoftDeleteMixin):
|
||||||
cover = db.Column(JSONB) # cover settings
|
cover = db.Column(JSONB) # cover settings
|
||||||
desc_data = db.Column(JSONB)
|
desc_data = db.Column(JSONB)
|
||||||
|
|
||||||
# Relationships
|
# Relationships - only active records
|
||||||
checklists = db.relationship(
|
checklists = db.relationship(
|
||||||
"Checklist", backref="card", cascade="all, delete-orphan", lazy="dynamic"
|
"Checklist",
|
||||||
|
backref="card",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Card.id == Checklist.card_id, Checklist.status == 'active')",
|
||||||
)
|
)
|
||||||
labels = db.relationship(
|
labels = db.relationship(
|
||||||
"CardLabel", backref="card", cascade="all, delete-orphan", lazy="dynamic"
|
"CardLabel",
|
||||||
|
backref="card",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Card.id == CardLabel.card_id, CardLabel.status == 'active')",
|
||||||
)
|
)
|
||||||
comments = db.relationship(
|
comments = db.relationship(
|
||||||
"Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic"
|
"Comment",
|
||||||
|
backref="card",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(Card.id == Comment.card_id, Comment.status == 'active')",
|
||||||
)
|
)
|
||||||
attachments = db.relationship(
|
attachments = db.relationship(
|
||||||
"FileAttachment",
|
"FileAttachment",
|
||||||
foreign_keys="FileAttachment.attachable_id",
|
foreign_keys="FileAttachment.attachable_id",
|
||||||
primaryjoin="""and_(FileAttachment.attachable_id == Card.id,
|
primaryjoin="""and_(FileAttachment.attachable_id == Card.id,
|
||||||
FileAttachment.attachable_type == 'Card')""",
|
FileAttachment.attachable_type == 'Card',
|
||||||
|
FileAttachment.status == 'active')""",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="dynamic",
|
lazy="dynamic",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Card link relationships (self-referential many-to-many)
|
# Card link relationships (self-referential many-to-many) - only active links
|
||||||
child_links = db.relationship(
|
child_links = db.relationship(
|
||||||
"CardLink",
|
"CardLink",
|
||||||
foreign_keys="CardLink.parent_card_id",
|
foreign_keys="CardLink.parent_card_id",
|
||||||
back_populates="parent_card",
|
back_populates="parent_card",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="dynamic",
|
lazy="dynamic",
|
||||||
|
primaryjoin="""and_(Card.id == CardLink.parent_card_id,
|
||||||
|
CardLink.status == 'active')""",
|
||||||
)
|
)
|
||||||
parent_links = db.relationship(
|
parent_links = db.relationship(
|
||||||
"CardLink",
|
"CardLink",
|
||||||
|
|
@ -84,6 +99,8 @@ class Card(db.Model, SoftDeleteMixin):
|
||||||
back_populates="child_card",
|
back_populates="child_card",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="dynamic",
|
lazy="dynamic",
|
||||||
|
primaryjoin="""and_(Card.id == CardLink.child_card_id,
|
||||||
|
CardLink.status == 'active')""",
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self, include_linked=False):
|
def to_dict(self, include_linked=False):
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,14 @@ class Checklist(db.Model, SoftDeleteMixin):
|
||||||
onupdate=lambda: datetime.now(UTC),
|
onupdate=lambda: datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships - only active check items
|
||||||
check_items = db.relationship(
|
check_items = db.relationship(
|
||||||
"CheckItem", backref="checklist", cascade="all, delete-orphan", lazy="dynamic"
|
"CheckItem",
|
||||||
|
backref="checklist",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="""and_(Checklist.id == CheckItem.checklist_id,
|
||||||
|
CheckItem.status == 'active')""",
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,13 @@ class List(db.Model, SoftDeleteMixin):
|
||||||
onupdate=lambda: datetime.now(UTC),
|
onupdate=lambda: datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships - only active cards
|
||||||
cards = db.relationship(
|
cards = db.relationship(
|
||||||
"Card", backref="list", cascade="all, delete-orphan", lazy="dynamic"
|
"Card",
|
||||||
|
backref="list",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
primaryjoin="and_(List.id == Card.list_id, Card.status == 'active')",
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ from flask_pydantic import validate
|
||||||
from app import db
|
from app import db
|
||||||
from app.decorators import load_board_owned
|
from app.decorators import load_board_owned
|
||||||
from app.decorators.decorators import get_current_user_id
|
from app.decorators.decorators import get_current_user_id
|
||||||
from app.models import (Board, Card, CardLabel, CheckItem, Checklist, Comment,
|
from app.models import Board, Card, List
|
||||||
Label, List)
|
|
||||||
from app.schemas import (BoardCreateRequest, BoardResponse,
|
from app.schemas import (BoardCreateRequest, BoardResponse,
|
||||||
BoardWithDetailsResponse)
|
BoardWithDetailsResponse)
|
||||||
|
|
||||||
|
|
@ -31,61 +30,46 @@ def get_board(board_id, board):
|
||||||
"""Get a single board with all its details"""
|
"""Get a single board with all its details"""
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
# Get all lists for this board (filter out soft-deleted lists)
|
# Get all lists for this board
|
||||||
lists_data = []
|
lists_data = []
|
||||||
for lst in (
|
for lst in board.lists.filter_by(closed=False).order_by(List.pos).all():
|
||||||
board.lists.filter_by(closed=False, deleted_at=None).order_by(List.pos).all()
|
|
||||||
):
|
|
||||||
cards_data = []
|
cards_data = []
|
||||||
# Filter out soft-deleted cards
|
for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all():
|
||||||
for card in (
|
|
||||||
lst.cards.filter_by(closed=False, deleted_at=None).order_by(Card.pos).all()
|
|
||||||
):
|
|
||||||
card_dict = card.to_dict()
|
card_dict = card.to_dict()
|
||||||
|
|
||||||
# Add labels for this card (filter out soft-deleted card-label associations)
|
# Add labels for this card
|
||||||
card_dict["labels"] = [
|
card_dict["labels"] = [
|
||||||
label.to_dict()
|
card_label.label.to_dict()
|
||||||
for label in (
|
for card_label in card.labels
|
||||||
db.session.query(Label)
|
if card_label.label and card_label.status == "active"
|
||||||
.join(CardLabel)
|
|
||||||
.filter(
|
|
||||||
CardLabel.card_id == card.id, CardLabel.deleted_at.is_(None)
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add comments for this card (filter out soft-deleted comments)
|
# Add comments for this card
|
||||||
card_dict["comments"] = []
|
card_dict["comments"] = []
|
||||||
for comment in card.comments.filter(Comment.deleted_at.is_(None)).all():
|
for comment in card.comments.all():
|
||||||
comment_dict = comment.to_dict()
|
comment_dict = comment.to_dict()
|
||||||
user = db.session.get(User, comment.user_id)
|
user = db.session.get(User, comment.user_id)
|
||||||
comment_dict["user"] = user.to_dict() if user else None
|
comment_dict["user"] = user.to_dict() if user else None
|
||||||
card_dict["comments"].append(comment_dict)
|
card_dict["comments"].append(comment_dict)
|
||||||
|
|
||||||
# Add checklists with items for this
|
# Add checklists with items
|
||||||
# card (filter out soft-deleted checklists and items)
|
from app.models import CheckItem, Checklist
|
||||||
|
|
||||||
card_dict["checklists"] = [
|
card_dict["checklists"] = [
|
||||||
{
|
{
|
||||||
**checklist.to_dict(),
|
**checklist.to_dict(),
|
||||||
"items": [
|
"items": [
|
||||||
item.to_dict()
|
item.to_dict()
|
||||||
for item in checklist.check_items.filter(
|
for item in CheckItem.active()
|
||||||
CheckItem.deleted_at.is_(None)
|
.filter_by(checklist_id=checklist.id)
|
||||||
).all()
|
.all()
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
for checklist in card.checklists.filter(
|
for checklist in Checklist.active().filter_by(card_id=card.id).all()
|
||||||
Checklist.deleted_at.is_(None)
|
|
||||||
).all()
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add epic for this card
|
# Add epic for this card
|
||||||
if card.epic:
|
card_dict["epic"] = card.epic.to_dict() if card.epic else None
|
||||||
card_dict["epic"] = card.epic.to_dict()
|
|
||||||
else:
|
|
||||||
card_dict["epic"] = None
|
|
||||||
|
|
||||||
cards_data.append(card_dict)
|
cards_data.append(card_dict)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function CardEpics({ cardEpics, boardId, cardId, refetchCard }: CardEpics
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await removeEpic(epicId, epicName);
|
const success = await removeEpic(epicId);
|
||||||
if (success) {
|
if (success) {
|
||||||
await refetchCard();
|
await refetchCard();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { CardLinksResponse } from '../types/kanban';
|
import type { CardLinksResponse } from '../types/kanban';
|
||||||
import LinkIcon from './icons/LinkIcon';
|
import LinkIcon from './icons/LinkIcon';
|
||||||
import UnlinkIcon from './icons/UnlinkIcon';
|
import UnlinkIcon from './icons/UnlinkIcon';
|
||||||
|
import { UnlinkCardModal } from './UnlinkCardModal';
|
||||||
|
|
||||||
interface CardLinksProps {
|
interface CardLinksProps {
|
||||||
links: CardLinksResponse;
|
links: CardLinksResponse;
|
||||||
|
|
@ -13,6 +15,18 @@ export function CardLinks({ links, boardId, onUnlink }: CardLinksProps) {
|
||||||
const { parent_cards: parentCards, child_cards: childCards } = links;
|
const { parent_cards: parentCards, child_cards: childCards } = links;
|
||||||
const hasLinks = parentCards.length > 0 || childCards.length > 0;
|
const hasLinks = parentCards.length > 0 || childCards.length > 0;
|
||||||
|
|
||||||
|
const [pendingUnlink, setPendingUnlink] = useState<{
|
||||||
|
id: number;
|
||||||
|
cardName: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleConfirmUnlink = () => {
|
||||||
|
if (pendingUnlink) {
|
||||||
|
onUnlink(pendingUnlink.id);
|
||||||
|
setPendingUnlink(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!hasLinks) {
|
if (!hasLinks) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +96,12 @@ export function CardLinks({ links, boardId, onUnlink }: CardLinksProps) {
|
||||||
{link.card.name}
|
{link.card.name}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => onUnlink(link.id)}
|
onClick={() =>
|
||||||
|
setPendingUnlink({
|
||||||
|
id: link.id,
|
||||||
|
cardName: link.card.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="text-gray-500 hover:text-red-400 transition-colors ml-2 p-1"
|
className="text-gray-500 hover:text-red-400 transition-colors ml-2 p-1"
|
||||||
title="Unlink card"
|
title="Unlink card"
|
||||||
>
|
>
|
||||||
|
|
@ -95,6 +114,16 @@ export function CardLinks({ links, boardId, onUnlink }: CardLinksProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingUnlink && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
|
<UnlinkCardModal
|
||||||
|
cardName={pendingUnlink.cardName}
|
||||||
|
onUnlink={handleConfirmUnlink}
|
||||||
|
onClose={() => setPendingUnlink(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
import { useState } from 'react';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
import CloseIcon from './icons/CloseIcon';
|
import CloseIcon from './icons/CloseIcon';
|
||||||
|
|
||||||
|
const linkedCardSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Card name is required')
|
||||||
|
.max(100, 'Card name must be less than 100 characters'),
|
||||||
|
description: z.string().max(2000, 'Description must be less than 2000 characters').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LinkedCardFormData = z.infer<typeof linkedCardSchema>;
|
||||||
|
|
||||||
interface CreateLinkedCardModalProps {
|
interface CreateLinkedCardModalProps {
|
||||||
parentCardName: string;
|
parentCardName: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -12,19 +24,16 @@ export function CreateLinkedCardModal({
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: CreateLinkedCardModalProps) {
|
}: CreateLinkedCardModalProps) {
|
||||||
const [name, setName] = useState('');
|
const {
|
||||||
const [description, setDescription] = useState('');
|
register,
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<LinkedCardFormData>({
|
||||||
|
resolver: zodResolver(linkedCardSchema),
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const onFormSubmit = async (data: LinkedCardFormData) => {
|
||||||
e.preventDefault();
|
await onSubmit(data.name.trim(), data.description?.trim() ?? '');
|
||||||
if (!name.trim()) return;
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await onSubmit(name.trim(), description.trim());
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -45,29 +54,35 @@ export function CreateLinkedCardModal({
|
||||||
<span className="text-white font-medium">{parentCardName}</span>
|
<span className="text-white font-medium">{parentCardName}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-1">
|
||||||
Card Name <span className="text-red-400">*</span>
|
Card Name <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
{...register('name')}
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
placeholder="Enter card name..."
|
placeholder="Enter card name..."
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-1">Description</label>
|
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
id="description"
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
{...register('description')}
|
||||||
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y min-h-[80px]"
|
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y min-h-[80px]"
|
||||||
placeholder="Add a description..."
|
placeholder="Add a description..."
|
||||||
/>
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="mt-1 text-sm text-red-400">{errors.description.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
|
@ -80,7 +95,7 @@ export function CreateLinkedCardModal({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!name.trim() || isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Creating...' : 'Create & Link'}
|
{isSubmitting ? 'Creating...' : 'Create & Link'}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import CloseIcon from './icons/CloseIcon';
|
import CloseIcon from './icons/CloseIcon';
|
||||||
import { useApi } from '../hooks/useApi';
|
import { useLinkExistingCard } from '../hooks/useLinkExistingCard';
|
||||||
import { useLoader } from '../context/loaders/useLoader';
|
|
||||||
import { useToast } from '../context/toasts/useToast';
|
|
||||||
import type { Card } from '../types/kanban';
|
|
||||||
|
|
||||||
interface LinkExistingCardModalProps {
|
interface LinkExistingCardModalProps {
|
||||||
boardId: number;
|
boardId: number;
|
||||||
|
|
@ -19,36 +16,9 @@ export function LinkExistingCardModal({
|
||||||
onLinked,
|
onLinked,
|
||||||
}: LinkExistingCardModalProps) {
|
}: LinkExistingCardModalProps) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [cards, setCards] = useState<Card[]>([]);
|
|
||||||
const [selectedCardId, setSelectedCardId] = useState<number | null>(null);
|
const [selectedCardId, setSelectedCardId] = useState<number | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const { getBoard, createCardLink } = useApi();
|
const { cards, isSubmitting, linkCard } = useLinkExistingCard(boardId, currentCardId);
|
||||||
const { withLoader } = useLoader();
|
|
||||||
const { addNotification } = useToast();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchCards = async () => {
|
|
||||||
try {
|
|
||||||
const board = await getBoard(boardId);
|
|
||||||
const allCards: Card[] = [];
|
|
||||||
for (const list of board.lists) {
|
|
||||||
allCards.push(...list.cards);
|
|
||||||
}
|
|
||||||
// Filter out the current card
|
|
||||||
setCards(allCards.filter((c) => c.id !== currentCardId));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Error',
|
|
||||||
message: 'Failed to load board cards',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchCards();
|
|
||||||
}, [getBoard, boardId, currentCardId, addNotification]);
|
|
||||||
|
|
||||||
const filteredCards = cards.filter(
|
const filteredCards = cards.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
|
|
@ -58,27 +28,10 @@ export function LinkExistingCardModal({
|
||||||
|
|
||||||
const handleLink = async () => {
|
const handleLink = async () => {
|
||||||
if (!selectedCardId) return;
|
if (!selectedCardId) return;
|
||||||
setIsSubmitting(true);
|
const success = await linkCard(selectedCardId);
|
||||||
try {
|
if (success) {
|
||||||
await withLoader(() => createCardLink(currentCardId, selectedCardId), 'Linking card...');
|
|
||||||
addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Card Linked',
|
|
||||||
message: 'Card linked successfully.',
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
onLinked();
|
onLinked();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to link card';
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Error',
|
|
||||||
message: msg,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
40
frontend/src/components/UnlinkCardModal.tsx
Normal file
40
frontend/src/components/UnlinkCardModal.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import UnlinkIcon from './icons/UnlinkIcon';
|
||||||
|
|
||||||
|
interface UnlinkCardModalProps {
|
||||||
|
cardName: string;
|
||||||
|
onUnlink: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnlinkCardModal({ cardName, onUnlink, onClose }: UnlinkCardModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="w-10 h-10 bg-orange-600 rounded-full flex items-center justify-center">
|
||||||
|
<span className="w-5 h-5">
|
||||||
|
<UnlinkIcon />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl font-bold text-white">Unlink Card</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300 mb-6">
|
||||||
|
Are you sure you want to unlink{' '}
|
||||||
|
<span className="text-white font-semibold">"{cardName}"</span> from this card?
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onUnlink}
|
||||||
|
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/hooks/useDocumentTitle.ts
Normal file
16
frontend/src/hooks/useDocumentTitle.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = 'Taskboard';
|
||||||
|
|
||||||
|
export function useDocumentTitle(title?: string) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (title) {
|
||||||
|
document.title = `${title} | ${DEFAULT_TITLE}`;
|
||||||
|
} else {
|
||||||
|
document.title = DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.title = DEFAULT_TITLE;
|
||||||
|
};
|
||||||
|
}, [title]);
|
||||||
|
}
|
||||||
68
frontend/src/hooks/useLinkExistingCard.ts
Normal file
68
frontend/src/hooks/useLinkExistingCard.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
import { useLoader } from '../context/loaders/useLoader';
|
||||||
|
import { useToast } from '../context/toasts/useToast';
|
||||||
|
import type { Card } from '../types/kanban';
|
||||||
|
|
||||||
|
export function useLinkExistingCard(boardId: number, currentCardId: number) {
|
||||||
|
const [cards, setCards] = useState<Card[]>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { getBoard, createCardLink } = useApi();
|
||||||
|
const { withLoader } = useLoader();
|
||||||
|
const { addNotification } = useToast();
|
||||||
|
|
||||||
|
const fetchCards = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const board = await getBoard(boardId);
|
||||||
|
const allCards: Card[] = [];
|
||||||
|
for (const list of board.lists) {
|
||||||
|
allCards.push(...list.cards);
|
||||||
|
}
|
||||||
|
setCards(allCards.filter((c) => c.id !== currentCardId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to load board cards',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [getBoard, boardId, currentCardId, addNotification]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCards();
|
||||||
|
}, [fetchCards]);
|
||||||
|
|
||||||
|
const linkCard = async (targetCardId: number): Promise<boolean> => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await withLoader(() => createCardLink(currentCardId, targetCardId), 'Linking card...');
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Card Linked',
|
||||||
|
message: 'Card linked successfully.',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to link card';
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error',
|
||||||
|
message: msg,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
isSubmitting,
|
||||||
|
linkCard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useBoard } from '../hooks/useBoard';
|
import { useBoard } from '../hooks/useBoard';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
import { useCardMutations } from '../hooks/useCardMutations';
|
import { useCardMutations } from '../hooks/useCardMutations';
|
||||||
import { useListMutations } from '../hooks/useListMutations';
|
import { useListMutations } from '../hooks/useListMutations';
|
||||||
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
|
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
|
||||||
|
|
@ -25,6 +26,7 @@ import { WidePageLayout } from '@/components/WidePageLayout';
|
||||||
export function BoardDetail() {
|
export function BoardDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { board, fetchBoard } = useBoard(parseInt(id || '0'));
|
const { board, fetchBoard } = useBoard(parseInt(id || '0'));
|
||||||
|
useDocumentTitle(board?.name);
|
||||||
const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard);
|
const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard);
|
||||||
const { createList, updateList, deleteList } = useListMutations(parseInt(id || '0'), fetchBoard);
|
const { createList, updateList, deleteList } = useListMutations(parseInt(id || '0'), fetchBoard);
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useCard } from '../hooks/useCard';
|
import { useCard } from '../hooks/useCard';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
|
import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
|
||||||
import { useChecklistMutations } from '../hooks/useChecklistMutations';
|
import { useChecklistMutations } from '../hooks/useChecklistMutations';
|
||||||
import { useLabels } from '../hooks/useLabels';
|
import { useLabels } from '../hooks/useLabels';
|
||||||
|
|
@ -28,6 +29,7 @@ export function CardDetail() {
|
||||||
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
|
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { card, fetchCard } = useCard(parseInt(cardId || '0'));
|
const { card, fetchCard } = useCard(parseInt(cardId || '0'));
|
||||||
|
useDocumentTitle(card?.name);
|
||||||
const {
|
const {
|
||||||
updateCardNameAndDescription,
|
updateCardNameAndDescription,
|
||||||
deleteCardWithConfirmation,
|
deleteCardWithConfirmation,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||||
import { WidePageLayout } from '../components/WidePageLayout';
|
import { WidePageLayout } from '../components/WidePageLayout';
|
||||||
import RichTextContent from '../components/RichTextContent';
|
import RichTextContent from '../components/RichTextContent';
|
||||||
import useEpicDetail from '../hooks/useEpicDetail';
|
import useEpicDetail from '../hooks/useEpicDetail';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
import Edit2Icon from '../components/icons/Edit2Icon';
|
import Edit2Icon from '../components/icons/Edit2Icon';
|
||||||
import Trash2Icon from '../components/icons/Trash2Icon';
|
import Trash2Icon from '../components/icons/Trash2Icon';
|
||||||
import ChevronRightIcon from '../components/icons/ChevronRightIcon';
|
import ChevronRightIcon from '../components/icons/ChevronRightIcon';
|
||||||
|
|
@ -12,6 +13,7 @@ export function EpicDetail() {
|
||||||
const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>();
|
const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { epic, deleteEpic } = useEpicDetail(epicId || '0');
|
const { epic, deleteEpic } = useEpicDetail(epicId || '0');
|
||||||
|
useDocumentTitle(epic?.name);
|
||||||
const [isContentExpanded, setIsContentExpanded] = useState(false);
|
const [isContentExpanded, setIsContentExpanded] = useState(false);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||||
import { WidePageLayout } from '../components/WidePageLayout';
|
import { WidePageLayout } from '../components/WidePageLayout';
|
||||||
import RichTextContent from '../components/RichTextContent';
|
import RichTextContent from '../components/RichTextContent';
|
||||||
import useWikiDetail from '../hooks/useWikiDetail';
|
import useWikiDetail from '../hooks/useWikiDetail';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
import Edit2Icon from '../components/icons/Edit2Icon';
|
import Edit2Icon from '../components/icons/Edit2Icon';
|
||||||
import Trash2Icon from '../components/icons/Trash2Icon';
|
import Trash2Icon from '../components/icons/Trash2Icon';
|
||||||
import ChevronRightIcon from '../components/icons/ChevronRightIcon';
|
import ChevronRightIcon from '../components/icons/ChevronRightIcon';
|
||||||
|
|
@ -12,6 +13,7 @@ export function WikiDetail() {
|
||||||
const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>();
|
const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { wiki, deleteWiki } = useWikiDetail(wikiId || '0');
|
const { wiki, deleteWiki } = useWikiDetail(wikiId || '0');
|
||||||
|
useDocumentTitle(wiki?.name);
|
||||||
const [isContentExpanded, setIsContentExpanded] = useState(false);
|
const [isContentExpanded, setIsContentExpanded] = useState(false);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
// User types
|
// User types
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
|
||||||
// Product types
|
|
||||||
export * from './product';
|
|
||||||
|
|
||||||
// Order types
|
|
||||||
export * from './order';
|
|
||||||
|
|
||||||
// API types
|
// API types
|
||||||
export * from './api';
|
export * from './api';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue