refactor active record usage

This commit is contained in:
david 2026-05-01 19:21:28 +03:00
parent 8e138689cf
commit 5b95262681
21 changed files with 319 additions and 158 deletions

View file

@ -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

View file

@ -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()
) )

View file

@ -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)

View file

@ -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.

View file

@ -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):

View file

@ -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):

View file

@ -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):

View file

@ -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):

View file

@ -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)

View file

@ -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();
} }

View file

@ -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>
); );
} }

View file

@ -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'}

View file

@ -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);
} }
}; };

View 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">&quot;{cardName}&quot;</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>
);
}

View 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]);
}

View 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,
};
}

View file

@ -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();

View file

@ -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,

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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';