Merge pull request 'initial-kanban-logic' (#4) from initial-kanban-logic into main

Reviewed-on: http://localhost:3000/david/flask_react_monorepo_template/pulls/4
This commit is contained in:
david 2026-02-28 00:26:10 +03:00
commit d2bc46ae91
28 changed files with 1887 additions and 955 deletions

View file

@ -56,8 +56,8 @@ class TestingConfig(Config):
# Conservative connection pool settings for testing
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 20, # Only one connection in the pool
"max_overflow": 10, # No overflow connections allowed
"pool_size": 1, # Only one connection in the pool
"max_overflow": 0, # No overflow connections allowed
"pool_timeout": 30,
"pool_recycle": 3600, # Recycle after 1 hour
"pool_pre_ping": True, # Verify connections before using

View file

@ -27,12 +27,15 @@ def get_boards():
@load_board_owned
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
lists_data = []
for lst in board.lists.filter_by(closed=False).order_by(List.pos).all():
cards_data = []
for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all():
card_dict = card.to_dict()
# Add labels for this card
card_dict["labels"] = [
label.to_dict()
@ -43,6 +46,24 @@ def get_board(board_id, board):
.all()
)
]
# Add comments for this card
card_dict["comments"] = []
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
card_dict["checklists"] = [
{
**checklist.to_dict(),
"items": [item.to_dict() for item in checklist.check_items.all()],
}
for checklist in card.checklists.all()
]
cards_data.append(card_dict)
lists_data.append({**lst.to_dict(), "cards": cards_data})

View file

@ -1,11 +1,14 @@
from datetime import UTC, datetime
from flask import request
from flask_jwt_extended import jwt_required
from flask_pydantic import validate
from app import db
from app.decorators import load_board_owned, load_list_owned
from app.models import List
from app.models import Board, List
from app.schemas import ListCreateRequest
from app.services.list_position_service import ListPositionService
from . import kanban_bp
@ -37,10 +40,22 @@ def update_list(list_id, lst, body: ListCreateRequest):
lst.name = body.name
if request.json.get("closed") is not None:
lst.closed = request.json.get("closed")
lst.pos = body.pos
# Track if position is changing
old_position = lst.pos
new_position = body.pos
if old_position != new_position:
# Use ListPositionService to reorder lists
ListPositionService.reorder_lists(lst.board_id, list_id, new_position)
else:
lst.pos = new_position
db.session.commit()
# Update board activity timestamp
board = db.session.get(Board, lst.board_id)
board.date_last_activity = datetime.now(UTC)
return lst.to_dict(), 200

View file

@ -105,3 +105,5 @@ class BoardWithDetailsResponse(BaseModel):
date_last_activity: Optional[datetime] = None
lists: List[Dict[str, Any]] = Field(default_factory=list)
labels: List[Dict[str, Any]] = Field(default_factory=list)
checklists: List[Dict[str, Any]] = Field(default_factory=list)
comments: List[Dict[str, Any]] = Field(default_factory=list)

View file

@ -0,0 +1,76 @@
"""Service for managing list positioning and reordering"""
from app import db
from app.models import List
class ListPositionService:
"""Service for handling list position management"""
@staticmethod
def reorder_lists(board_id: int, moved_list_id: int, new_position: float) -> None:
"""
Reorder all lists in a board when one list is moved to a new position.
Args:
board_id: The ID of board containing lists
moved_list_id: The ID of list being moved
new_position: The new position for moved list
"""
# Get all lists in board, ordered by their current position
all_lists = List.query.filter_by(board_id=board_id).order_by(List.pos).all()
# Find moved list in board
moved_list = None
other_lists = []
for lst in all_lists:
if lst.id == moved_list_id:
moved_list = lst
else:
other_lists.append(lst)
if not moved_list:
return # List not found in this board
# Insert moved list at the new position
other_lists.insert(int(new_position), moved_list)
# Update positions for all lists to ensure unique, sequential positions
for index, lst in enumerate(other_lists):
lst.pos = float(index)
db.session.commit()
@staticmethod
def get_next_position(board_id: int) -> float:
"""
Get next available position in a board.
Args:
board_id: The ID of board
Returns:
The next available position (float)
"""
last_list = (
List.query.filter_by(board_id=board_id).order_by(List.pos.desc()).first()
)
return float(last_list.pos + 1) if last_list else 0.0
@staticmethod
def ensure_unique_positions(board_id: int) -> None:
"""
Ensure all lists in a board have unique, sequential positions.
Useful for data cleanup.
Args:
board_id: The ID of board to fix
"""
lists = List.query.filter_by(board_id=board_id).order_by(List.pos).all()
for index, lst in enumerate(lists):
lst.pos = float(index)
db.session.commit()

View file

@ -11,7 +11,7 @@ log = logging.getLogger(__name__)
fake = Faker()
@pytest.fixture(scope="session")
@pytest.fixture(scope="function")
def app():
"""Create application for testing with PostgreSQL database (session scope)"""
app = create_app(config_name="test")
@ -35,6 +35,7 @@ def app():
# Cleanup after all tests
db.session.remove()
db.drop_all()
db.engine.dispose()
@pytest.fixture

View file

@ -68,7 +68,7 @@ class TestListRoutes:
assert response.status_code == 404
def test_update_list_success(self, client, db_session, regular_user, auth_headers):
"""Test updating a list"""
"""Test updating a list name (position stays same for single list)"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
@ -80,13 +80,13 @@ class TestListRoutes:
response = client.put(
f"/api/lists/{lst.id}",
headers=auth_headers,
json={"name": "Updated Name", "pos": 1},
json={"name": "Updated Name", "pos": 0},
)
assert response.status_code == 200
data = response.get_json()
assert data["name"] == "Updated Name"
assert data["pos"] == 1
assert data["pos"] == 0
def test_update_list_not_found(self, client, db_session, auth_headers):
"""Test updating a non-existent list"""
@ -149,3 +149,71 @@ class TestListRoutes:
deleted_card = db.session.get(Card, card.id)
assert deleted_list is None
assert deleted_card is None
def test_update_list_position_reorders_others(
self, client, db_session, regular_user, auth_headers
):
"""Test updating list position reorders other lists in the board"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
# Create 3 lists in sequential positions
list1 = List(name="List 1", board_id=board.id, pos=0)
list2 = List(name="List 2", board_id=board.id, pos=1)
list3 = List(name="List 3", board_id=board.id, pos=2)
db_session.add(list1)
db_session.add(list2)
db_session.add(list3)
db_session.commit()
# Move list3 from position 2 to position 0 (front)
response = client.put(
f"/api/lists/{list3.id}",
headers=auth_headers,
json={"name": "List 3", "pos": 0},
)
assert response.status_code == 200
# Verify all lists have unique, sequential positions
updated_lists = List.query.filter_by(board_id=board.id).order_by(List.pos).all()
assert len(updated_lists) == 3
assert updated_lists[0].id == list3.id
assert updated_lists[0].pos == 0.0
assert updated_lists[1].id == list1.id
assert updated_lists[1].pos == 1.0
assert updated_lists[2].id == list2.id
assert updated_lists[2].pos == 2.0
def test_update_list_position_no_change(
self, client, db_session, regular_user, auth_headers
):
"""Test updating list with same position doesn't reorder others"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
list1 = List(name="List 1", board_id=board.id, pos=0)
list2 = List(name="List 2", board_id=board.id, pos=1)
db_session.add(list1)
db_session.add(list2)
db_session.commit()
original_pos1 = list1.pos
original_pos2 = list2.pos
# Update list2 but keep same position
response = client.put(
f"/api/lists/{list2.id}",
headers=auth_headers,
json={"name": "Updated List 2", "pos": original_pos2},
)
assert response.status_code == 200
# Verify positions unchanged
updated_list1 = db.session.get(List, list1.id)
updated_list2 = db.session.get(List, list2.id)
assert updated_list1.pos == original_pos1
assert updated_list2.pos == original_pos2

View file

@ -1,514 +0,0 @@
# Kanban API Documentation
This document describes all the API endpoints for the Kanban application. All endpoints require authentication unless otherwise noted.
## Base URL
```
http://localhost:5000/api
```
## Authentication
All endpoints (except register and login) require a JWT token in the Authorization header:
```
Authorization: Bearer <your_jwt_token>
```
## Boards
### Get All Boards
```
GET /api/boards
```
Get all boards for the current authenticated user.
**Response:** Array of board objects
### Get Single Board
```
GET /api/boards/<board_id>
```
Get a board with all its details including lists, cards, and labels.
**Response:** Board object with nested lists, cards, and labels
### Create Board
```
POST /api/boards
```
Create a new board.
**Request Body:**
```json
{
"name": "My Project Board",
"description": "Project management board",
"url": "https://example.com/board/123",
"short_link": "abc123",
"short_url": "https://example.com/b/abc123",
"prefs": {},
"label_names": {},
"limits": {}
}
```
**Response:** Created board object
### Update Board
```
PUT /api/boards/<board_id>
```
Update a board.
**Request Body:** Partial board object (only include fields to update)
**Response:** Updated board object
### Delete Board
```
DELETE /api/boards/<board_id>
```
Delete a board and all its associated data.
**Response:** Success message
## Lists
### Create List
```
POST /api/boards/<board_id>/lists
```
Create a new list in a board.
**Request Body:**
```json
{
"name": "To Do",
"pos": 0
}
```
**Response:** Created list object
### Update List
```
PUT /api/lists/<list_id>
```
Update a list.
**Request Body:** Partial list object
**Response:** Updated list object
### Delete List
```
DELETE /api/lists/<list_id>
```
Delete a list and all its cards.
**Response:** Success message
## Cards
### Create Card
```
POST /api/lists/<list_id>/cards
```
Create a new card in a list.
**Request Body:**
```json
{
"name": "Task Name",
"description": "Task description",
"pos": 0,
"due": "2026-12-31T23:59:59Z",
"due_complete": false,
"badges": {},
"cover": {},
"desc_data": {}
}
```
**Response:** Created card object
### Get Single Card
```
GET /api/cards/<card_id>
```
Get a card with full details including labels, checklists, and comments.
**Response:** Card object with nested labels, checklists, items, and comments
### Update Card
```
PUT /api/cards/<card_id>
```
Update a card. Can also move card to different list by providing `list_id`.
**Request Body:** Partial card object
**Response:** Updated card object
### Delete Card
```
DELETE /api/cards/<card_id>
```
Delete a card and all its associated data.
**Response:** Success message
## Labels
### Get Board Labels
```
GET /api/boards/<board_id>/labels
```
Get all labels for a board.
**Response:** Array of label objects
### Create Label
```
POST /api/boards/<board_id>/labels
```
Create a new label in a board.
**Request Body:**
```json
{
"name": "Urgent",
"color": "red"
}
```
**Response:** Created label object
### Add Label to Card
```
POST /api/cards/<card_id>/labels
```
Add a label to a card.
**Request Body:**
```json
{
"label_id": 1
}
```
**Response:** Created card-label association object
### Remove Label from Card
```
DELETE /api/cards/<card_id>/labels/<label_id>
```
Remove a label from a card.
**Response:** Success message
## Checklists
### Create Checklist
```
POST /api/cards/<card_id>/checklists
```
Create a new checklist in a card.
**Request Body:**
```json
{
"name": "Tasks",
"pos": 0
}
```
**Response:** Created checklist object
### Create Check Item
```
POST /api/checklists/<checklist_id>/items
```
Create a new check item in a checklist.
**Request Body:**
```json
{
"name": "Complete task",
"pos": 0,
"state": "incomplete",
"due": "2026-12-31T23:59:59Z"
}
```
**Response:** Created check item object
### Update Check Item
```
PUT /api/check-items/<item_id>
```
Update a check item (typically used to toggle state).
**Request Body:** Partial check item object
**Response:** Updated check item object
### Delete Checklist
```
DELETE /api/checklists/<checklist_id>
```
Delete a checklist and all its items.
**Response:** Success message
### Delete Check Item
```
DELETE /api/check-items/<item_id>
```
Delete a check item.
**Response:** Success message
## Comments
### Get Card Comments
```
GET /api/cards/<card_id>/comments
```
Get all comments for a card, ordered by creation date (newest first).
**Response:** Array of comment objects with user information
### Create Comment
```
POST /api/cards/<card_id>/comments
```
Create a new comment on a card.
**Request Body:**
```json
{
"text": "This is a comment"
}
```
**Response:** Created comment object
### Update Comment
```
PUT /api/comments/<comment_id>
```
Update a comment (only by the comment author).
**Request Body:** Partial comment object
**Response:** Updated comment object
### Delete Comment
```
DELETE /api/comments/<comment_id>
```
Delete a comment (only by the comment author).
**Response:** Success message
## Data Models
### Board
```json
{
"id": 1,
"name": "My Board",
"description": "Board description",
"closed": false,
"url": "https://example.com",
"short_link": "abc123",
"short_url": "https://example.com/b/abc123",
"user_id": 1,
"date_last_activity": "2026-01-01T00:00:00Z",
"date_last_view": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"prefs": {},
"label_names": {},
"limits": {}
}
```
### List
```json
{
"id": 1,
"name": "To Do",
"closed": false,
"pos": 0,
"board_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
```
### Card
```json
{
"id": 1,
"name": "Task Name",
"description": "Task description",
"closed": false,
"due": "2026-12-31T23:59:59Z",
"due_complete": false,
"pos": 0,
"id_short": 1,
"board_id": 1,
"list_id": 1,
"date_last_activity": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"badges": {},
"cover": {},
"desc_data": {},
"labels": [],
"checklists": [],
"comments": []
}
```
### Label
```json
{
"id": 1,
"name": "Urgent",
"color": "red",
"uses": 0,
"board_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
```
### Checklist
```json
{
"id": 1,
"name": "Tasks",
"pos": 0,
"board_id": 1,
"card_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"items": []
}
```
### Check Item
```json
{
"id": 1,
"name": "Complete task",
"pos": 0,
"state": "incomplete",
"due": "2026-12-31T23:59:59Z",
"checklist_id": 1,
"user_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
```
### Comment
```json
{
"id": 1,
"text": "Comment text",
"card_id": 1,
"user_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"user": {}
}
```
## Common Response Codes
- `200 OK` - Request successful
- `201 Created` - Resource created successfully
- `400 Bad Request` - Invalid request data
- `401 Unauthorized` - Authentication required or invalid
- `403 Forbidden` - Access denied (not your resource)
- `404 Not Found` - Resource not found
- `500 Internal Server Error` - Server error
## Usage Examples
### Creating a complete board with lists and cards
```bash
# 1. Login to get token
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "password"}'
# 2. Create a board
curl -X POST http://localhost:5000/api/boards \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "My Project"}'
# 3. Create a list
curl -X POST http://localhost:5000/api/boards/1/lists \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "To Do", "pos": 0}'
# 4. Create a card
curl -X POST http://localhost:5000/api/lists/1/cards \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "Task 1", "pos": 0}'
```
### Adding labels to a card
```bash
# 1. Create a label
curl -X POST http://localhost:5000/api/boards/1/labels \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "Urgent", "color": "red"}'
# 2. Add label to card
curl -X POST http://localhost:5000/api/cards/1/labels \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"label_id": 1}'
```
### Creating a checklist with items
```bash
# 1. Create checklist
curl -X POST http://localhost:5000/api/cards/1/checklists \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "Subtasks", "pos": 0}'
# 2. Add check items
curl -X POST http://localhost:5000/api/checklists/1/items \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "Task 1", "pos": 0}'
# 3. Mark item as complete
curl -X PUT http://localhost:5000/api/check-items/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"state": "complete"}'
```
### Adding comments
```bash
curl -X POST http://localhost:5000/api/cards/1/comments \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"text": "This is a comment"}'

View file

@ -1,300 +0,0 @@
# Kanban Backend Implementation Summary
## Overview
This document summarizes the Kanban backend implementation for the project. The backend provides a complete API for managing Kanban boards, lists, cards, labels, checklists, and comments.
## Database Schema
### Tables Created
1. **boards** - Main board entities
- id, name, description, closed, url, short_link, short_url
- user_id (foreign key to users)
- date_last_activity, date_last_view, created_at, updated_at
- JSONB fields: prefs, label_names, limits
- Relationships: lists, cards, labels
2. **lists** - Board columns/lists
- id, name, closed, pos
- board_id (foreign key to boards)
- created_at, updated_at
- Relationships: cards
3. **cards** - Task cards within lists
- id, name, description, closed, due, due_complete, pos, id_short
- board_id (foreign key to boards), list_id (foreign key to lists)
- date_last_activity, created_at, updated_at
- JSONB fields: badges, cover, desc_data
- Relationships: checklists, labels, comments
4. **labels** - Color-coded tags
- id, name, color, uses
- board_id (foreign key to boards)
- created_at, updated_at
- Relationships: card_labels
5. **card_labels** - Many-to-many relationship between cards and labels
- id, card_id, label_id
- Unique constraint on (card_id, label_id)
6. **checklists** - Checklists within cards
- id, name, pos
- board_id (foreign key to boards), card_id (foreign key to cards)
- created_at, updated_at
- Relationships: check_items
7. **check_items** - Items within checklists
- id, name, pos, state, due
- checklist_id (foreign key to checklists), user_id (foreign key to users)
- created_at, updated_at
8. **comments** - Comments on cards
- id, text
- card_id (foreign key to cards), user_id (foreign key to users)
- created_at, updated_at
## API Endpoints
### Boards (`/api/boards`)
- `GET /api/boards` - Get all boards for current user
- `GET /api/boards/<id>` - Get board with full details (lists, cards, labels)
- `POST /api/boards` - Create new board
- `PUT /api/boards/<id>` - Update board
- `DELETE /api/boards/<id>` - Delete board
### Lists (`/api/lists`, `/api/boards/<id>/lists`)
- `POST /api/boards/<board_id>/lists` - Create list in board
- `PUT /api/lists/<id>` - Update list
- `DELETE /api/lists/<id>` - Delete list
### Cards (`/api/cards`, `/api/lists/<id>/cards`)
- `POST /api/lists/<list_id>/cards` - Create card in list
- `GET /api/cards/<id>` - Get card with full details
- `PUT /api/cards/<id>` - Update card (includes moving between lists)
- `DELETE /api/cards/<id>` - Delete card
### Labels (`/api/boards/<id>/labels`, `/api/cards/<id>/labels`)
- `GET /api/boards/<board_id>/labels` - Get all labels for board
- `POST /api/boards/<board_id>/labels` - Create label
- `POST /api/cards/<card_id>/labels` - Add label to card
- `DELETE /api/cards/<card_id>/labels/<label_id>` - Remove label from card
### Checklists (`/api/checklists`, `/api/cards/<id>/checklists`)
- `POST /api/cards/<card_id>/checklists` - Create checklist
- `DELETE /api/checklists/<id>` - Delete checklist
### Check Items (`/api/check-items`, `/api/checklists/<id>/items`)
- `POST /api/checklists/<checklist_id>/items` - Create check item
- `PUT /api/check-items/<id>` - Update check item
- `DELETE /api/check-items/<id>` - Delete check item
### Comments (`/api/comments`, `/api/cards/<id>/comments`)
- `GET /api/cards/<card_id>/comments` - Get all comments for card
- `POST /api/cards/<card_id>/comments` - Create comment
- `PUT /api/comments/<id>` - Update comment
- `DELETE /api/comments/<id>` - Delete comment
## Key Features
### Authentication
- All endpoints (except register/login) require JWT authentication
- User can only access their own boards and related resources
- Comments can only be edited/deleted by their author
### Data Integrity
- Cascading deletes ensure cleanup of related data
- Foreign key constraints maintain referential integrity
- Unique constraints prevent duplicate card-label associations
### Position Tracking
- All orderable entities (lists, cards, checklists, check items) have `pos` field
- Enables flexible sorting and drag-and-drop functionality
### Activity Tracking
- Boards track `date_last_activity` and `date_last_view`
- Cards track `date_last_activity`
- Useful for showing recent activity
### JSONB Fields
- Flexible storage for complex data (prefs, limits, badges, cover, desc_data)
- Allows extensibility without schema changes
- Supports Trello-like feature parity
## File Structure
```
backend/app/
├── models/
│ ├── board.py # Board model
│ ├── list_model.py # List model (named to avoid Python conflict)
│ ├── card.py # Card model
│ ├── label.py # Label model
│ ├── card_label.py # Card-Label junction table
│ ├── checklist.py # Checklist model
│ ├── check_item.py # CheckItem model
│ ├── comment.py # Comment model
│ └── user.py # Updated with boards relationship
├── routes/
│ └── kanban.py # All Kanban API routes
└── __init__.py # Updated to import models and register blueprint
docs/
├── kanban_api.md # Complete API documentation
└── kanban_implementation_summary.md # This file
```
## Migration
Migration file: `backend/migrations/versions/1c0b9dfbd933_add_kanban_models_board_list_card_label_.py`
To apply migrations:
```bash
cd backend && . venv/bin/activate && flask db upgrade
```
## Usage Flow
### Typical User Workflow
1. **Register/Login**
- User registers account or logs in
- Receives JWT token for authentication
2. **Create Board**
- User creates a new board
- Board is associated with their user ID
3. **Add Lists**
- User adds lists (columns) to the board
- Examples: "To Do", "In Progress", "Done"
4. **Add Cards**
- User creates cards within lists
- Cards can have descriptions, due dates, etc.
5. **Enhance Cards**
- Add labels for categorization
- Add checklists for subtasks
- Add comments for collaboration
6. **Manage Work**
- Move cards between lists (drag-and-drop)
- Update card details
- Mark checklist items as complete
- Delete completed items
## Design Decisions
### Single User App
- No organization or membership models (as specified)
- Each board belongs to exactly one user
- Simplifies permissions model
### Trello-Inspired Schema
- Uses similar field names and structure as Trello
- Makes it familiar to users
- Supports importing from Trello JSON exports
### Position-Based Ordering
- Uses float `pos` field for ordering
- Allows inserting items between others
- Supports infinite granularity for drag-and-drop
### Cascading Deletes
- Deleting a board deletes all its lists, cards, labels, etc.
- Deleting a list deletes all its cards
- Ensures no orphaned data
### JSONB for Flexible Data
- Stores complex nested data without normalized tables
- Simplifies schema for optional features
- Maintains flexibility for future enhancements
## Next Steps
### Backend
- [ ] Add validation schemas (Pydantic)
- [ ] Add comprehensive tests
- [ ] Add rate limiting
- [ ] Add file upload support for attachments
- [ ] Add activity logging/history
- [ ] Add search functionality
- [ ] Add filtering and sorting options
### Frontend
- [ ] Create board list view
- [ ] Create board detail view with drag-and-drop
- [ ] Implement card creation/editing
- [ ] Implement label management
- [ ] Implement checklist functionality
- [ ] Implement comments
- [ ] Add real-time updates (WebSocket)
## Testing
Test the API using the examples in `docs/kanban_api.md` or use tools like:
- Postman
- Insomnia
- curl (command line)
Example:
```bash
# Start the backend server
make dev-services # Start postgres & redis
make dev-backend # Start Flask server
# Test endpoints
curl http://localhost:5000/api/health
```
## Security Considerations
- JWT authentication required for all operations
- User isolation: users can only access their own resources
- SQL injection prevention through SQLAlchemy ORM
- Input validation on all endpoints
- CORS configured for frontend integration
## Performance Notes
- Indexed fields: user_id, board_id, list_id, card_id, etc.
- Lazy loading relationships to avoid N+1 queries
- Efficient queries using SQLAlchemy's query builder
- JSONB fields use PostgreSQL's optimized JSON storage
## Troubleshooting
### Migration Issues
```bash
# Reset migrations (WARNING: deletes data)
cd backend && . venv/bin/activate && flask db downgrade base
rm -rf migrations/versions/*.py
flask db migrate -m "Initial migration"
flask db upgrade
```
### Database Connection Issues
```bash
# Restart services
make dev-stop-services
make dev-services
```
### Import Errors
```bash
# Ensure venv is activated
cd backend && . venv/bin/activate
pip install -r requirements/dev.txt
```
## Summary
The Kanban backend is now fully implemented with:
- ✅ Complete database schema
- ✅ RESTful API endpoints
- ✅ Authentication and authorization
- ✅ Data integrity and validation
- ✅ Comprehensive documentation
- ✅ Database migration
The backend is ready for frontend integration and testing.

View file

@ -0,0 +1,216 @@
import CheckSquareIcon from './icons/CheckSquareIcon';
import Trash2Icon from './icons/Trash2Icon';
import Edit2Icon from './icons/Edit2Icon';
import PlusIcon from './icons/PlusIcon';
import { useModal } from '../context/modals/useModal';
import { CreateChecklistModal } from './CreateChecklistModal';
import { DeleteChecklistModal } from './DeleteChecklistModal';
import { EditCheckItemModal } from './EditCheckItemModal';
import { CheckItem } from '../types/kanban';
interface CardChecklistsProps {
checklists: any[];
cardId: number;
addChecklist: (name: string, pos: number) => Promise<boolean>;
removeChecklist: (id: number) => Promise<boolean>;
addCheckItem: (
checklistId: number,
name: string,
pos: number,
state?: 'incomplete' | 'complete'
) => Promise<boolean>;
toggleCheckItem: (item: CheckItem, currentState: 'incomplete' | 'complete') => Promise<boolean>;
editCheckItem: (
itemId: number,
name: string,
pos: number,
state: 'incomplete' | 'complete'
) => Promise<boolean>;
removeCheckItem: (itemId: number) => Promise<boolean>;
}
export function CardChecklists({
checklists,
addChecklist,
removeChecklist,
addCheckItem,
toggleCheckItem,
editCheckItem,
removeCheckItem,
}: CardChecklistsProps) {
const { openModal } = useModal();
const handleAddChecklist = () => {
openModal((props) => (
<CreateChecklistModal
onSave={async (name) => {
const checklistCount = checklists?.length || 0;
return await addChecklist(name, checklistCount);
}}
onClose={props.onClose}
/>
));
};
const handleDeleteChecklist = (checklist: any) => {
openModal((props) => (
<DeleteChecklistModal
checklistName={checklist.name}
onDelete={async () => {
const success = await removeChecklist(checklist.id);
if (success) {
props.onClose();
}
}}
onClose={props.onClose}
/>
));
};
const handleAddCheckItem = (checklist: any) => {
const itemName = prompt('Enter item name:');
if (itemName && itemName.trim()) {
const itemCount = checklist.items?.length || 0;
addCheckItem(checklist.id, itemName.trim(), itemCount);
}
};
const handleToggleCheckItem = async (item: any) => {
await toggleCheckItem(item, item.state);
};
const handleEditCheckItem = (item: any) => {
openModal((props) => (
<EditCheckItemModal
itemName={item.name}
onSave={async (name) => {
return await editCheckItem(item.id, name, item.pos, item.state);
}}
onClose={props.onClose}
/>
));
};
const handleDeleteCheckItem = (item: any) => {
if (confirm('Are you sure you want to delete this item?')) {
removeCheckItem(item.id);
}
};
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
Checklists
</h2>
<button
onClick={handleAddChecklist}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-1.5 px-3 rounded-lg transition-colors flex items-center gap-1"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Add Checklist
</button>
</div>
{checklists && checklists.length > 0 ? (
<div className="space-y-4">
{checklists.map((checklist: any) => {
const completedCount =
checklist.items?.filter((item: any) => item.state === 'complete').length || 0;
const totalCount = checklist.items?.length || 0;
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
return (
<div key={checklist.id} className="border border-gray-700 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="font-semibold text-white mb-1">{checklist.name}</h3>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-gray-400 whitespace-nowrap">
{completedCount}/{totalCount}
</span>
</div>
</div>
<button
onClick={() => handleDeleteChecklist(checklist)}
className="text-gray-400 hover:text-red-400 transition-colors ml-2"
title="Delete checklist"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
</button>
</div>
<div className="space-y-2">
{checklist.items && checklist.items.length > 0 ? (
checklist.items.map((item: any) => (
<div
key={item.id}
className="flex items-center gap-3 p-2 bg-gray-700 rounded group hover:bg-gray-600 transition-colors"
>
<input
type="checkbox"
checked={item.state === 'complete'}
onChange={() => handleToggleCheckItem(item)}
className="w-5 h-5 rounded cursor-pointer"
/>
<span
onClick={() => handleToggleCheckItem(item)}
className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
>
{item.name}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleEditCheckItem(item)}
className="text-gray-400 hover:text-white transition-colors p-1"
title="Edit item"
>
<span className="w-3.5 h-3.5">
<Edit2Icon />
</span>
</button>
<button
onClick={() => handleDeleteCheckItem(item)}
className="text-gray-400 hover:text-red-400 transition-colors p-1"
title="Delete item"
>
<span className="w-3.5 h-3.5">
<Trash2Icon />
</span>
</button>
</div>
</div>
))
) : (
<p className="text-gray-400 text-sm">No items yet</p>
)}
<button
onClick={() => handleAddCheckItem(checklist)}
className="text-blue-400 hover:text-blue-300 text-sm font-medium mt-2 flex items-center gap-1"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Add an item
</button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-400 text-sm">No checklists yet. Add one to get started!</p>
)}
</div>
);
}

View file

@ -0,0 +1,79 @@
import { useModal } from '../context/modals/useModal';
import { CreateLabelModal } from './CreateLabelModal';
import { LabelDropdown } from './LabelDropdown';
import TagIcon from './icons/TagIcon';
import { Label } from '../types/kanban';
interface CardLabelsProps {
labels: Label[];
cardLabels: Label[];
addLabel: (name: string, color: string) => Promise<boolean>;
addLabelToCard: (labelId: number) => Promise<boolean>;
removeLabelFromCard: (labelId: number) => Promise<boolean>;
refetchLabels: () => Promise<Label[]>;
refetchCard: () => Promise<any>;
}
export function CardLabels({
labels,
cardLabels,
addLabel,
addLabelToCard,
removeLabelFromCard,
refetchLabels,
refetchCard,
}: CardLabelsProps) {
const { openModal } = useModal();
const handleCreateLabel = () => {
openModal((props) => (
<CreateLabelModal
onSave={async (name, color) => {
const success = await addLabel(name, color);
if (success) {
// Refetch both labels and card data to show new labels
await refetchLabels();
await refetchCard();
}
return success;
}}
onClose={props.onClose}
/>
));
};
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="w-5 h-5">
<TagIcon />
</span>
Labels
</h2>
<LabelDropdown
labels={labels}
cardLabels={cardLabels}
onAddLabel={addLabelToCard}
onRemoveLabel={removeLabelFromCard}
onCreateLabel={handleCreateLabel}
/>
</div>
<div className="flex flex-wrap gap-2">
{cardLabels && cardLabels.length > 0 ? (
cardLabels.map((label) => (
<span
key={label.id}
className="px-3 py-1 rounded-full text-sm font-medium"
style={{ backgroundColor: label.color, color: 'white' }}
>
{label.name}
</span>
))
) : (
<p className="text-gray-400 text-sm">No labels added to this card</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,155 @@
import { CardWithDetails } from '../types/kanban';
import { Link } from 'react-router-dom';
interface CardPreviewModalProps {
card: CardWithDetails;
onClose: () => void;
}
export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<Link
to={`/boards/${card.board_id}/cards/${card.id}`}
className="text-2xl font-bold text-blue-400 hover:text-blue-300 hover:underline transition-colors"
>
{card.name}
</Link>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors text-2xl leading-none"
>
×
</button>
</div>
{/* Labels */}
{card.labels && card.labels.length > 0 && (
<div className="mb-4">
<div className="flex flex-wrap gap-2">
{card.labels.map((label) => (
<span
key={label.id}
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: label.color }}
>
{label.name}
</span>
))}
</div>
</div>
)}
{/* Description */}
{card.description && (
<div className="mb-4">
<h3 className="text-lg font-semibold text-white mb-2">Description</h3>
<p className="text-gray-300 whitespace-pre-wrap">{card.description}</p>
</div>
)}
{/* Checklists */}
{card.checklists && card.checklists.length > 0 && (
<div className="mb-4">
<h3 className="text-lg font-semibold text-white mb-2">Checklists</h3>
<div className="space-y-3">
{card.checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.state === 'complete'
).length;
const totalItems = checklist.items.length;
const progress = totalItems > 0 ? (completedItems / totalItems) * 100 : 0;
return (
<div key={checklist.id} className="bg-gray-700 rounded-lg p-4">
<div className="flex justify-between items-center mb-3">
<h4 className="font-semibold text-white">{checklist.name}</h4>
<span className="text-sm text-gray-400">
{completedItems}/{totalItems}
</span>
</div>
<div className="w-full h-2 bg-gray-600 rounded-full overflow-hidden mb-3">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
{/* Checklist Items */}
<div className="space-y-2">
{checklist.items.map((item) => (
<div key={item.id} className="flex items-start gap-2 text-sm">
<div
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
item.state === 'complete'
? 'bg-green-500 border-green-500'
: 'border-gray-500'
}`}
>
{item.state === 'complete' && (
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
<span
className={`text-gray-300 ${
item.state === 'complete' ? 'line-through text-gray-500' : ''
}`}
>
{item.name}
</span>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Comments Preview */}
{card.comments && card.comments.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-white mb-2">Comments</h3>
<div className="space-y-3 max-h-64 overflow-y-auto">
{card.comments.slice(0, 3).map((comment) => (
<div key={comment.id} className="bg-gray-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold text-xs">
{comment.user?.username.charAt(0).toUpperCase() || '?'}
</div>
<div>
<p className="text-white text-sm font-medium">
{comment.user?.username || 'Unknown'}
</p>
<p className="text-gray-400 text-xs">
{new Date(comment.created_at).toLocaleDateString()}
</p>
</div>
</div>
<p className="text-gray-300 text-sm line-clamp-3">{comment.text}</p>
</div>
))}
{card.comments.length > 3 && (
<p className="text-gray-400 text-sm text-center">
+{card.comments.length - 3} more comments
</p>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,55 @@
import { useState } from 'react';
interface CreateChecklistModalProps {
onSave: (name: string) => Promise<boolean>;
onClose: () => void;
}
export function CreateChecklistModal({ onSave, onClose }: CreateChecklistModalProps) {
const [name, setName] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const success = await onSave(name);
if (success) {
onClose();
}
};
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Add Checklist</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Title</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Checklist title..."
autoFocus
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Add
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,97 @@
import { useState } from 'react';
interface CreateLabelModalProps {
onSave: (name: string, color: string) => Promise<boolean>;
onClose: () => void;
}
const COLORS = [
{ name: 'Red', value: '#ef4444' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Yellow', value: '#eab308' },
{ name: 'Green', value: '#22c55e' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Purple', value: '#a855f7' },
{ name: 'Pink', value: '#ec4899' },
{ name: 'Gray', value: '#6b7280' },
];
export function CreateLabelModal({ onSave, onClose }: CreateLabelModalProps) {
const [name, setName] = useState('');
const [color, setColor] = useState(COLORS[0].value);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const success = await onSave(name, color);
if (success) {
onClose();
}
};
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Create Label</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Label Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Enter label name..."
autoFocus
/>
</div>
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Color</label>
<div className="grid grid-cols-4 gap-2">
{COLORS.map((colorOption) => (
<button
key={colorOption.value}
type="button"
onClick={() => setColor(colorOption.value)}
className={`h-10 rounded-lg border-2 transition-all ${
color === colorOption.value
? 'border-white ring-2 ring-white/50'
: 'border-transparent hover:border-gray-500'
}`}
style={{ backgroundColor: colorOption.value }}
title={colorOption.name}
/>
))}
</div>
</div>
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Preview</label>
<div className="flex items-center gap-2">
<span
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: color }}
>
{name || 'Label Preview'}
</span>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Create
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,45 @@
import Trash2Icon from './icons/Trash2Icon';
interface DeleteChecklistModalProps {
checklistName: string;
onDelete: () => void;
onClose: () => void;
}
export function DeleteChecklistModal({
checklistName,
onDelete,
onClose,
}: DeleteChecklistModalProps) {
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-red-600 rounded-full flex items-center justify-center">
<span className="w-5 h-5">
<Trash2Icon />
</span>
</span>
<h3 className="text-xl font-bold text-white">Delete Checklist</h3>
</div>
<p className="text-gray-300 mb-6">
Are you sure you want to delete
<span className="text-white font-semibold">&quot;{checklistName}&quot;</span>? This action
cannot be undone.
</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={onDelete}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { useState, useEffect } from 'react';
interface EditCheckItemModalProps {
itemName: string;
onSave: (name: string) => Promise<boolean>;
onClose: () => void;
}
export function EditCheckItemModal({ itemName, onSave, onClose }: EditCheckItemModalProps) {
const [name, setName] = useState(itemName);
useEffect(() => {
setName(itemName);
}, [itemName]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const success = await onSave(name);
if (success) {
onClose();
}
};
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Edit Item</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Item Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Item name..."
autoFocus
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Save
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,129 @@
import { useState, useRef, useEffect } from 'react';
import { Label } from '../types/kanban';
import PlusIcon from './icons/PlusIcon';
interface LabelDropdownProps {
labels: Label[];
cardLabels: Label[];
onAddLabel: (labelId: number) => Promise<boolean>;
onRemoveLabel: (labelId: number) => Promise<boolean>;
onCreateLabel: () => void;
}
export function LabelDropdown({
labels,
cardLabels,
onAddLabel,
onRemoveLabel,
onCreateLabel,
}: LabelDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
const cardLabelIds = new Set(cardLabels.map((l) => l.id));
const availableLabels = labels.filter((l) => !cardLabelIds.has(l.id));
const handleToggleLabel = async (labelId: number, isAdded: boolean) => {
let success = false;
if (isAdded) {
success = await onRemoveLabel(labelId);
} else {
success = await onAddLabel(labelId);
}
if (success) {
setIsOpen(false);
}
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white px-3 py-1.5 rounded-lg transition-colors text-sm"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Add Label
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-2 w-72 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50">
<div className="p-4">
<h4 className="text-white font-medium mb-3">Labels</h4>
{/* Add new label button */}
<button
onClick={() => {
onCreateLabel();
setIsOpen(false);
}}
className="w-full mb-3 flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg transition-colors text-sm"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Create new label
</button>
{/* Available labels */}
{availableLabels.length > 0 ? (
<div className="space-y-2">
<p className="text-gray-400 text-xs uppercase tracking-wide">Available Labels</p>
{availableLabels.map((label) => (
<button
key={label.id}
onClick={() => handleToggleLabel(label.id, false)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-700 transition-colors text-left"
>
<span
className="w-6 h-6 rounded-full"
style={{ backgroundColor: label.color }}
/>
<span className="text-gray-200 text-sm">{label.name}</span>
</button>
))}
</div>
) : (
<p className="text-gray-400 text-sm">No labels available</p>
)}
{/* Current labels (can be removed) */}
{cardLabels.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-700 space-y-2">
<p className="text-gray-400 text-xs uppercase tracking-wide">Current Labels</p>
{cardLabels.map((label) => (
<button
key={label.id}
onClick={() => handleToggleLabel(label.id, true)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-700 transition-colors text-left"
>
<span
className="w-6 h-6 rounded-full border-2 border-white"
style={{ backgroundColor: label.color }}
/>
<span className="text-gray-200 text-sm">{label.name}</span>
<span className="ml-auto text-xs text-gray-400">Remove</span>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,81 @@
import { ModalContentProps } from '../../types';
import Trash2Icon from '../icons/Trash2Icon';
interface DeleteListModalProps extends ModalContentProps {
listName: string;
onDelete: () => Promise<void>;
}
export function DeleteListModal({ onClose, onDelete, listName }: DeleteListModalProps) {
const handleDelete = async () => {
try {
await onDelete();
onClose();
} catch (err) {
console.error('Failed to delete list:', err);
}
};
return (
<div className="bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-700">
<div className="flex justify-between items-center p-6 border-b border-gray-700">
<h2 id="modal-title" className="text-xl font-bold text-white">
Delete List
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="p-6">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-red-600/20 rounded-full flex items-center justify-center">
<span className="w-6 h-6">
<Trash2Icon />
</span>
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-2">
Delete &quot;{listName}&quot;?
</h3>
<p className="text-gray-400 text-sm">
This will permanently delete the list and all cards in it. This action cannot be
undone.
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Delete List
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ModalContentProps } from '../../types';
import { useToast } from '../../context/toasts/useToast';
const listSchema = z.object({
name: z
.string()
.min(1, 'List name is required')
.max(100, 'List name must be less than 100 characters'),
});
type ListFormData = z.infer<typeof listSchema>;
interface EditListModalProps extends ModalContentProps {
listName: string;
onSave: (name: string) => Promise<void>;
}
export function EditListModal({ onClose, onSave, listName }: EditListModalProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ListFormData>({
resolver: zodResolver(listSchema),
defaultValues: {
name: listName,
},
});
const { addNotification } = useToast();
const onSubmit = async (data: ListFormData) => {
try {
await onSave(data.name);
onClose();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update list';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
}
};
return (
<div className="bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-700">
<div className="flex justify-between items-center p-6 border-b border-gray-700">
<h2 id="modal-title" className="text-xl font-bold text-white">
Edit List
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="p-6">
<div className="mb-6">
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
List Name <span className="text-red-400">*</span>
</label>
<input
id="name"
type="text"
{...register('name')}
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., To Do, In Progress, Done"
autoFocus
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Save Changes
</button>
</div>
</form>
</div>
);
}

View file

@ -1,13 +1,16 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Card as CardType } from '../../types/kanban';
import MessageSquareIcon from '../icons/MessageSquareIcon';
import CheckSquareIcon from '../icons/CheckSquareIcon';
import { Link } from 'react-router-dom';
interface KanbanCardProps {
card: CardType;
onClick: () => void;
onOpenModal: () => void;
}
export function KanbanCard({ card, onClick }: KanbanCardProps) {
export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `CARD_${card.id}`,
});
@ -18,28 +21,109 @@ export function KanbanCard({ card, onClick }: KanbanCardProps) {
opacity: isDragging ? 0.5 : 1,
};
// Calculate checklist progress
const checklists = (card as any).checklists || [];
const totalItems = checklists.reduce(
(sum: number, checklist: any) => sum + checklist.items.length,
0
);
const completedItems = checklists.reduce(
(sum: number, checklist: any) =>
sum + checklist.items.filter((item: any) => item.state === 'complete').length,
0
);
const hasChecklists = totalItems > 0;
// Calculate comment count
const comments = (card as any).comments || [];
const commentCount = comments.length;
const hasComments = commentCount > 0;
// Get labels
const labels = (card as any).labels || [];
const hasLabels = labels.length > 0;
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={onClick}
onClick={(e) => {
// Only open modal if not clicking title link
if (!(e.target as HTMLElement).closest('a')) {
onOpenModal();
}
}}
className="bg-gray-700 rounded-lg p-4 mb-3 cursor-pointer hover:bg-gray-600 transition-colors border border-gray-600 shadow-sm"
>
<h3 className="text-white font-medium mb-2">{card.name}</h3>
{card.description && (
<p className="text-gray-400 text-sm mb-2 line-clamp-2">{card.description}</p>
{/* Labels */}
{hasLabels && (
<div className="flex flex-wrap gap-1 mb-2">
{labels.slice(0, 3).map((label: any) => (
<span
key={label.id}
className="px-2 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: label.color }}
>
{label.name}
</span>
))}
{labels.length > 3 && (
<span className="px-2 py-0.5 rounded text-xs font-medium text-gray-400">
+{labels.length - 3}
</span>
)}
</div>
)}
<div className="flex items-center justify-between text-xs text-gray-500">
<span>#{card.id_short}</span>
<h3 className="text-white font-medium mb-2 hover:text-blue-400 transition-colors">
<Link
to={`/boards/${card.board_id}/cards/${card.id}`}
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>
{card.name}
</Link>
</h3>
{card.description && (
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{card.description}</p>
)}
{/* Badges */}
<div className="flex items-center gap-3 text-xs text-gray-400 mt-3 pt-2 border-t border-gray-600">
{/* Comments Badge */}
{hasComments && (
<div className="flex items-center gap-1">
<span className="w-3.5 h-3.5">
<MessageSquareIcon />
</span>
<span>{commentCount}</span>
</div>
)}
{/* Checklist Badge */}
{hasChecklists && (
<div className="flex items-center gap-1">
<span className="w-3.5 h-3.5">
<CheckSquareIcon />
</span>
<span>
{completedItems}/{totalItems}
</span>
</div>
)}
{/* Due Date Badge */}
{card.due && (
<span className={card.due_complete ? 'text-green-400' : 'text-yellow-400'}>
Due: {new Date(card.due).toLocaleDateString()}
</span>
)}
{/* Card ID */}
<span className="text-gray-500">#{card.id_short}</span>
</div>
</div>
);

View file

@ -3,16 +3,34 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { ListWithCards, Card as CardType } from '../../types/kanban';
import { KanbanCard } from './KanbanCard';
import { CreateCardModal } from './CreateCardModal';
import { EditListModal } from './EditListModal';
import { DeleteListModal } from './DeleteListModal';
import Edit2Icon from '../icons/Edit2Icon';
import Trash2Icon from '../icons/Trash2Icon';
import { useModal } from '../../context/modals/useModal';
interface KanbanColumnProps {
export interface KanbanColumnProps {
list: ListWithCards;
cards: CardType[];
onCardClick: (card: CardType) => void;
onOpenCardModal: (card: CardType) => void;
onCardCreate: (data: { name: string; description?: string }) => Promise<void>;
onListEdit?: (name: string) => Promise<void>;
onListDelete?: () => Promise<void>;
dragHandleProps?: {
attributes: any;
listeners: any;
};
}
export function KanbanColumn({ list, cards, onCardClick, onCardCreate }: KanbanColumnProps) {
export function KanbanColumn({
list,
cards,
onOpenCardModal,
onCardCreate,
onListEdit,
onListDelete,
dragHandleProps,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: `LIST_${list.id}`,
});
@ -23,14 +41,90 @@ export function KanbanColumn({ list, cards, onCardClick, onCardCreate }: KanbanC
openModal((props) => <CreateCardModal {...props} onCreate={onCardCreate} />);
};
const handleEditList = () => {
if (!onListEdit) return;
openModal((props) => (
<EditListModal
{...props}
listName={list.name}
onSave={async (name) => {
await onListEdit(name);
}}
/>
));
};
const handleDeleteList = () => {
if (!onListDelete) return;
openModal((props) => (
<DeleteListModal
{...props}
listName={list.name}
onDelete={async () => {
await onListDelete();
}}
/>
));
};
return (
<div className="bg-gray-800 rounded-lg p-4 min-w-[300px] max-w-[300px] border border-gray-700 flex flex-col">
<h2 className="text-white font-bold text-lg mb-4 flex items-center justify-between">
{list.name}
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full">
{cards.length}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-1">
{/* Drag Handle Icon */}
<div {...dragHandleProps?.attributes} {...dragHandleProps?.listeners}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-500 cursor-grab hover:text-gray-300"
>
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
<h2 className="text-white font-bold text-lg">{list.name}</h2>
</div>
<div className="flex items-center gap-2">
{onListEdit && (
<button
onClick={handleEditList}
className="text-gray-400 hover:text-blue-400 transition-colors"
title="Edit list"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
</h2>
</button>
)}
{onListDelete && (
<button
onClick={handleDeleteList}
className="text-gray-400 hover:text-red-400 transition-colors"
title="Delete list"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
</button>
)}
</div>
</div>
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block">
{cards.length} cards
</span>
</div>
<SortableContext
id={list.id.toString()}
@ -42,7 +136,7 @@ export function KanbanColumn({ list, cards, onCardClick, onCardCreate }: KanbanC
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''}`}
>
{cards.map((card) => (
<KanbanCard key={card.id} card={card} onClick={() => onCardClick(card)} />
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />
))}
</div>
</SortableContext>

View file

@ -0,0 +1,25 @@
import { useDraggable } from '@dnd-kit/core';
import { KanbanColumn, KanbanColumnProps } from './KanbanColumn';
export function SortableKanbanColumn(props: KanbanColumnProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `COLUMN_${props.list.id}`,
data: {
type: 'column',
columnId: props.list.id,
},
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
opacity: isDragging ? 0.5 : 1,
}
: undefined;
return (
<div ref={setNodeRef} style={style}>
<KanbanColumn {...props} dragHandleProps={{ attributes, listeners }} />
</div>
);
}

View file

@ -0,0 +1,167 @@
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { CheckItem } from '../types/kanban';
export function useChecklistMutations(cardId: number, onUpdate: () => void) {
const { createChecklist, deleteChecklist, createCheckItem, updateCheckItem, deleteCheckItem } =
useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const addChecklist = async (name: string, pos: number) => {
try {
await withLoader(() => createChecklist(cardId, { name, pos }), 'Creating checklist...');
onUpdate();
addNotification({
type: 'success',
title: 'Checklist Created',
message: 'Checklist has been created successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create checklist';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const removeChecklist = async (checklistId: number) => {
try {
await withLoader(() => deleteChecklist(checklistId), 'Deleting checklist...');
onUpdate();
addNotification({
type: 'success',
title: 'Checklist Deleted',
message: 'Checklist has been deleted successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete checklist';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const addCheckItem = async (
checklistId: number,
name: string,
pos: number,
state: 'incomplete' | 'complete' = 'incomplete'
) => {
try {
await withLoader(() => createCheckItem(checklistId, { name, pos, state }), 'Adding item...');
onUpdate();
addNotification({
type: 'success',
title: 'Item Added',
message: 'Check item has been added successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add item';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const toggleCheckItem = async (item: CheckItem, currentState: 'incomplete' | 'complete') => {
console.log('item', item);
try {
const newState = currentState === 'incomplete' ? 'complete' : 'incomplete';
await withLoader(
() => updateCheckItem(item.id, { name: item.name, pos: 0, state: newState }),
'Updating item...'
);
onUpdate();
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update item';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const editCheckItem = async (
itemId: number,
name: string,
pos: number,
state: 'incomplete' | 'complete'
) => {
try {
await withLoader(() => updateCheckItem(itemId, { name, pos, state }), 'Updating item...');
onUpdate();
addNotification({
type: 'success',
title: 'Item Updated',
message: 'Check item has been updated successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update item';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const removeCheckItem = async (itemId: number) => {
try {
await withLoader(() => deleteCheckItem(itemId), 'Deleting item...');
onUpdate();
addNotification({
type: 'success',
title: 'Item Deleted',
message: 'Check item has been deleted successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete item';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
return {
addChecklist,
removeChecklist,
addCheckItem,
toggleCheckItem,
editCheckItem,
removeCheckItem,
};
}

View file

@ -0,0 +1,93 @@
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
export function useLabelMutations(boardId: number, onUpdate: () => void) {
const { createLabel } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const addLabel = async (name: string, color: string) => {
try {
await withLoader(() => createLabel(boardId, { name, color }), 'Creating label...');
onUpdate();
addNotification({
type: 'success',
title: 'Label Created',
message: 'Label has been created successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create label';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
return {
addLabel,
};
}
export function useCardLabelMutations(cardId: number, onUpdate: () => void) {
const { addLabelToCard, removeLabelFromCard } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const addLabelToCardMutation = async (labelId: number) => {
try {
await withLoader(() => addLabelToCard(cardId, labelId), 'Adding label...');
onUpdate();
addNotification({
type: 'success',
title: 'Label Added',
message: 'Label has been added to the card.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add label';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const removeLabelFromCardMutation = async (labelId: number) => {
try {
await withLoader(() => removeLabelFromCard(cardId, labelId), 'Removing label...');
onUpdate();
addNotification({
type: 'success',
title: 'Label Removed',
message: 'Label has been removed from the card.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to remove label';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
return {
addLabelToCardMutation,
removeLabelFromCardMutation,
};
}

View file

@ -0,0 +1,46 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { Label } from '../types/kanban';
export function useLabels(boardId: number) {
const [labels, setLabels] = useState<Label[]>([]);
const [error, setError] = useState<Error | null>(null);
const { getLabels } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchLabels = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getLabels(boardId), 'Loading labels...');
setLabels(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load labels';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Labels',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [boardId, getLabels, withLoader, addNotification]);
useEffect(() => {
if (boardId) {
fetchLabels();
}
}, [boardId, fetchLabels]);
return {
labels,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
refetch: fetchLabels,
};
}

View file

@ -36,6 +36,7 @@ export function useListMutations(boardId: number, onUpdate: () => void) {
() => updateList(listId, { ...data, closed: false }),
'Updating list...'
);
onUpdate(); // Refresh board data after update
addNotification({
type: 'success',
title: 'List Updated',
@ -64,6 +65,7 @@ export function useListMutations(boardId: number, onUpdate: () => void) {
message: 'List deleted successfully.',
duration: 3000,
});
onUpdate(); // Refresh board data after delete
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete list';
addNotification({

View file

@ -1,9 +1,10 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import { useBoard } from '../hooks/useBoard';
import { useCardMutations } from '../hooks/useCardMutations';
import { useListMutations } from '../hooks/useListMutations';
import { KanbanColumn } from '../components/kanban/KanbanColumn';
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
import { CreateListModal } from '../components/kanban/CreateListModal';
import { CardPreviewModal } from '../components/CardPreviewModal';
import { useModal } from '../context/modals/useModal';
import {
DndContext,
@ -16,18 +17,19 @@ import {
useSensors,
closestCenter,
} from '@dnd-kit/core';
import { Card as CardType } from '../types/kanban';
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { Card as CardType, ListWithCards } from '../types/kanban';
import { useState } from 'react';
export function BoardDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { board, fetchBoard } = useBoard(parseInt(id || '0'));
const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard);
const { createList } = useListMutations(parseInt(id || '0'), fetchBoard);
const { createList, updateList, deleteList } = useListMutations(parseInt(id || '0'), fetchBoard);
const { openModal } = useModal();
const [activeCard, setActiveCard] = useState<CardType | null>(null);
const [activeList, setActiveList] = useState<ListWithCards | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
@ -39,20 +41,25 @@ export function BoardDetail() {
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const [activeType, activeIdStr] = (active.id as string).split('_');
const activeIdStr = (active.id as string).split('_')[1];
const cardId = parseInt(activeIdStr as string);
if (activeType === 'COLUMN') {
// Dragging a column
const listId = parseInt(activeIdStr);
const list = board?.lists.find((l) => l.id === listId);
if (list) {
setActiveList(list);
}
} else if (activeType === 'CARD') {
// Dragging a card
const cardId = parseInt(activeIdStr);
if (board) {
const card = board.lists.flatMap((list) => list.cards).find((c) => c.id === cardId);
// console.log('---handleDragStart', event, card)
// console.log('---handleDragStart.board', board)
if (card) {
setActiveCard(card);
}
}
}
};
const handleDragOver = (event: DragOverEvent) => {
@ -85,19 +92,60 @@ export function BoardDetail() {
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
setActiveCard(null);
setActiveList(null);
if (!over || !board) return;
// console.log('--------------over', over)
// console.log('--------------board', board)
const [activeType, activeIdStr] = (active.id as string).split('_');
const [overType, overIdStr] = (over.id as string).split('_');
const overId = parseInt(overIdStr, 10);
const activeIdStr = (active.id as string).split('_')[1];
const activeId = parseInt(activeIdStr, 10);
// debugger
const overId = parseInt(overIdStr, 10);
if (active.id === over.id) return;
// Handle column reordering
if (activeType === 'COLUMN') {
// todo find over column id,
let overListIndex = -1;
const activeList = board.lists.find((l) => l.id === activeId);
if (overType === 'CARD') {
overListIndex = board.lists.findIndex((l) => {
const foundIndex = l.cards.findIndex((card) => card.id === overId);
return foundIndex >= 0;
});
} else if (overType === 'LIST') {
overListIndex = board.lists.findIndex((l) => l.id === overId);
}
// console.log('-------active.id', active.id)
// console.log('-------overType.id', overType)
// console.log('-------overListIndex', overListIndex)
const activeListIndex = board.lists.findIndex((l) => l.id === activeId);
// overListIndex = board.lists.findIndex((l) => l.id === overId);
if (activeListIndex === -1 || overListIndex === -1 || !activeList) return;
// Calculate new positions for all lists
const reorderedLists = [...board.lists];
const [movedList] = reorderedLists.splice(activeListIndex, 1);
reorderedLists.splice(overListIndex, 0, movedList);
await updateList(activeList.id, { name: activeList.name, pos: overListIndex });
// // Update all list positions
// for (let i = 0; i < reorderedLists.length; i++) {
// const list = reorderedLists[i];
// if (list.pos !== i) {
// await updateList(list.id, { name: list.name, pos: i });
// }
// }
return;
}
// Handle card reordering (existing logic)
// Find active card
let activeCard: CardType | undefined;
let activeList: (typeof board.lists)[0] | undefined;
@ -114,8 +162,7 @@ export function BoardDetail() {
if (!activeCard || !activeList) return;
// Check if we're dropping on a list or a card
// debugger
if (overType.toLocaleLowerCase() === 'list') {
if (overType.toLowerCase() === 'list') {
const overList = board.lists.find((list) => list.id === overId);
// Dropping on a list - append to end
@ -143,25 +190,11 @@ export function BoardDetail() {
// Calculate new position
const overCardIndex = overListContainingCard.cards.findIndex((c) => c.id === overId);
// // If dropping on to same list and after of same card, do nothing
// if (
// overListContainingCard.id === activeList.id &&
// overCardIndex === activeList.cards.findIndex((c) => c.id === activeId) + 1
// ) {
// console.log('--------------over', over)
// console.log('--------------board', board)
// console.log('--------------activeCard', activeCard)
// console.log('--------------overListContainingCard', overListContainingCard)
// return;
// }
await moveCard(activeCard, activeList.id, overListContainingCard.id, overCardIndex);
};
const handleCardClick = (card: CardType) => {
navigate(`/boards/${id}/cards/${card.id}`);
const handleOpenCardModal = (card: CardType) => {
openModal((props) => <CardPreviewModal card={card as any} onClose={props.onClose} />);
};
const handleAddList = () => {
@ -176,6 +209,17 @@ export function BoardDetail() {
));
};
const handleEditList = async (listId: number, name: string) => {
const list = board?.lists.find((l) => l.id === listId);
if (list) {
await updateList(listId, { name, pos: list.pos });
}
};
const handleDeleteList = async (listId: number) => {
await deleteList(listId);
};
const handleAddCard =
(listId: number) => async (data: { name: string; description?: string }) => {
await createCard(listId, {
@ -222,24 +266,38 @@ export function BoardDetail() {
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext
items={board.lists.map((list) => `COLUMN_${list.id}`)}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-4 overflow-x-auto pb-4">
{board.lists.map((list) => (
<KanbanColumn
<SortableKanbanColumn
key={list.id}
list={list}
cards={list.cards}
onCardClick={handleCardClick}
onOpenCardModal={handleOpenCardModal}
onCardCreate={handleAddCard(list.id)}
onListEdit={(name) => handleEditList(list.id, name)}
onListDelete={() => handleDeleteList(list.id)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeCard ? (
<div className="bg-gray-700 rounded-lg p-4 cursor-grabbing border border-gray-600 shadow-lg opacity-80">
<h3 className="text-white font-medium">{activeCard.name}</h3>
</div>
) : activeList ? (
<div className="bg-gray-800 rounded-lg p-4 cursor-grabbing border border-gray-700 shadow-lg opacity-80 min-w-[300px] max-w-[300px]">
<h2 className="text-white font-bold text-lg">{activeList.name}</h2>
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block mt-2">
{activeList.cards.length} cards
</span>
</div>
) : null}
</DragOverlay>
</DndContext>

View file

@ -1,13 +1,16 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCard } from '../hooks/useCard';
import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
import { useChecklistMutations } from '../hooks/useChecklistMutations';
import { useLabels } from '../hooks/useLabels';
import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations';
import { useModal } from '../context/modals/useModal';
import { CardSidebar } from '../components/CardSidebar';
import { CardComments } from '../components/CardComments';
import { CardChecklists } from '../components/CardChecklists';
import { CardLabels } from '../components/CardLabels';
import { EditCardModal } from '../components/EditCardModal';
import { DeleteCardModal } from '../components/DeleteCardModal';
import CheckSquareIcon from '../components/icons/CheckSquareIcon';
import TagIcon from '../components/icons/TagIcon';
import Trash2Icon from '../components/icons/Trash2Icon';
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
import Edit2Icon from '../components/icons/Edit2Icon';
@ -25,6 +28,15 @@ export function CardDetail() {
} = useCardDetailMutations(parseInt(cardId || '0'), card, fetchCard);
const { openModal } = useModal();
const checklistMutations = useChecklistMutations(parseInt(cardId || '0'), fetchCard);
// Labels functionality
const { labels, refetch: refetchLabels } = useLabels(parseInt(boardId || '0'));
const { addLabel } = useLabelMutations(parseInt(boardId || '0'), refetchLabels);
const { addLabelToCardMutation, removeLabelFromCardMutation } = useCardLabelMutations(
parseInt(cardId || '0'),
fetchCard
);
const handleEditCard = () => {
if (!card) return;
@ -130,70 +142,27 @@ export function CardDetail() {
</div>
{/* Labels Section */}
{card.labels && card.labels.length > 0 && (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4">
<span className="w-5 h-5">
<TagIcon />
</span>
Labels
</h2>
<div className="flex flex-wrap gap-2">
{card.labels.map((label: any) => (
<span
key={label.id}
className="px-3 py-1 rounded-full text-sm font-medium"
style={{ backgroundColor: label.color, color: 'white' }}
>
{label.name}
</span>
))}
</div>
</div>
)}
<CardLabels
labels={labels}
cardLabels={card.labels || []}
addLabel={addLabel}
addLabelToCard={addLabelToCardMutation}
removeLabelFromCard={removeLabelFromCardMutation}
refetchLabels={refetchLabels}
refetchCard={fetchCard}
/>
{/* Checklists Section */}
{card.checklists && card.checklists.length > 0 && (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4">
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
Checklists
</h2>
<div className="space-y-4">
{card.checklists.map((checklist: any) => (
<div key={checklist.id} className="border border-gray-700 rounded-lg p-4">
<h3 className="font-semibold text-white mb-3">{checklist.name}</h3>
<div className="space-y-2">
{checklist.items && checklist.items.length > 0 ? (
checklist.items.map((item: any) => (
<div
key={item.id}
className="flex items-center gap-3 p-2 bg-gray-700 rounded"
>
<input
type="checkbox"
checked={item.state === 'complete'}
readOnly
className="w-5 h-5 rounded"
<CardChecklists
checklists={card.checklists || []}
cardId={parseInt(cardId || '0')}
addChecklist={checklistMutations.addChecklist}
removeChecklist={checklistMutations.removeChecklist}
addCheckItem={checklistMutations.addCheckItem}
toggleCheckItem={checklistMutations.toggleCheckItem}
editCheckItem={checklistMutations.editCheckItem}
removeCheckItem={checklistMutations.removeCheckItem}
/>
<span
className={`text-sm ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
>
{item.name}
</span>
</div>
))
) : (
<p className="text-gray-400 text-sm">No items yet</p>
)}
</div>
</div>
))}
</div>
</div>
)}
<CardComments
card={card}