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