diff --git a/backend/app/config.py b/backend/app/config.py index 1e8478b..8984c09 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/routes/kanban/boards.py b/backend/app/routes/kanban/boards.py index b69ed4e..05c6455 100644 --- a/backend/app/routes/kanban/boards.py +++ b/backend/app/routes/kanban/boards.py @@ -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}) diff --git a/backend/app/routes/kanban/lists.py b/backend/app/routes/kanban/lists.py index 6e4749c..ff1d5a2 100644 --- a/backend/app/routes/kanban/lists.py +++ b/backend/app/routes/kanban/lists.py @@ -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,9 +40,21 @@ 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 - db.session.commit() + # 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 diff --git a/backend/app/schemas/board.py b/backend/app/schemas/board.py index ef98a32..89fd41e 100644 --- a/backend/app/schemas/board.py +++ b/backend/app/schemas/board.py @@ -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) diff --git a/backend/app/services/list_position_service.py b/backend/app/services/list_position_service.py new file mode 100644 index 0000000..4cf3a4e --- /dev/null +++ b/backend/app/services/list_position_service.py @@ -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() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4c7abb2..87790f6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/routes/test_lists.py b/backend/tests/routes/test_lists.py index d7f8dfb..dcb8e4f 100644 --- a/backend/tests/routes/test_lists.py +++ b/backend/tests/routes/test_lists.py @@ -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 diff --git a/docs/kanban_api.md b/docs/kanban_api.md deleted file mode 100644 index e85b04c..0000000 --- a/docs/kanban_api.md +++ /dev/null @@ -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 -``` - -## 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/ -``` -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/ -``` -Update a board. - -**Request Body:** Partial board object (only include fields to update) - -**Response:** Updated board object - -### Delete Board -``` -DELETE /api/boards/ -``` -Delete a board and all its associated data. - -**Response:** Success message - -## Lists - -### Create List -``` -POST /api/boards//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/ -``` -Update a list. - -**Request Body:** Partial list object - -**Response:** Updated list object - -### Delete List -``` -DELETE /api/lists/ -``` -Delete a list and all its cards. - -**Response:** Success message - -## Cards - -### Create Card -``` -POST /api/lists//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/ -``` -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/ -``` -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/ -``` -Delete a card and all its associated data. - -**Response:** Success message - -## Labels - -### Get Board Labels -``` -GET /api/boards//labels -``` -Get all labels for a board. - -**Response:** Array of label objects - -### Create Label -``` -POST /api/boards//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//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//labels/ -``` -Remove a label from a card. - -**Response:** Success message - -## Checklists - -### Create Checklist -``` -POST /api/cards//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//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/ -``` -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/ -``` -Delete a checklist and all its items. - -**Response:** Success message - -### Delete Check Item -``` -DELETE /api/check-items/ -``` -Delete a check item. - -**Response:** Success message - -## Comments - -### Get Card Comments -``` -GET /api/cards//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//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/ -``` -Update a comment (only by the comment author). - -**Request Body:** Partial comment object - -**Response:** Updated comment object - -### Delete Comment -``` -DELETE /api/comments/ -``` -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 " \ - -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 " \ - -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 " \ - -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 " \ - -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 " \ - -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 " \ - -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 " \ - -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 " \ - -d '{"state": "complete"}' -``` - -### Adding comments - -```bash -curl -X POST http://localhost:5000/api/cards/1/comments \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -d '{"text": "This is a comment"}' \ No newline at end of file diff --git a/docs/kanban_implementation_summary.md b/docs/kanban_implementation_summary.md deleted file mode 100644 index d8f9d93..0000000 --- a/docs/kanban_implementation_summary.md +++ /dev/null @@ -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/` - Get board with full details (lists, cards, labels) -- `POST /api/boards` - Create new board -- `PUT /api/boards/` - Update board -- `DELETE /api/boards/` - Delete board - -### Lists (`/api/lists`, `/api/boards//lists`) -- `POST /api/boards//lists` - Create list in board -- `PUT /api/lists/` - Update list -- `DELETE /api/lists/` - Delete list - -### Cards (`/api/cards`, `/api/lists//cards`) -- `POST /api/lists//cards` - Create card in list -- `GET /api/cards/` - Get card with full details -- `PUT /api/cards/` - Update card (includes moving between lists) -- `DELETE /api/cards/` - Delete card - -### Labels (`/api/boards//labels`, `/api/cards//labels`) -- `GET /api/boards//labels` - Get all labels for board -- `POST /api/boards//labels` - Create label -- `POST /api/cards//labels` - Add label to card -- `DELETE /api/cards//labels/` - Remove label from card - -### Checklists (`/api/checklists`, `/api/cards//checklists`) -- `POST /api/cards//checklists` - Create checklist -- `DELETE /api/checklists/` - Delete checklist - -### Check Items (`/api/check-items`, `/api/checklists//items`) -- `POST /api/checklists//items` - Create check item -- `PUT /api/check-items/` - Update check item -- `DELETE /api/check-items/` - Delete check item - -### Comments (`/api/comments`, `/api/cards//comments`) -- `GET /api/cards//comments` - Get all comments for card -- `POST /api/cards//comments` - Create comment -- `PUT /api/comments/` - Update comment -- `DELETE /api/comments/` - 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. \ No newline at end of file diff --git a/frontend/src/components/CardChecklists.tsx b/frontend/src/components/CardChecklists.tsx new file mode 100644 index 0000000..aee5601 --- /dev/null +++ b/frontend/src/components/CardChecklists.tsx @@ -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; + removeChecklist: (id: number) => Promise; + addCheckItem: ( + checklistId: number, + name: string, + pos: number, + state?: 'incomplete' | 'complete' + ) => Promise; + toggleCheckItem: (item: CheckItem, currentState: 'incomplete' | 'complete') => Promise; + editCheckItem: ( + itemId: number, + name: string, + pos: number, + state: 'incomplete' | 'complete' + ) => Promise; + removeCheckItem: (itemId: number) => Promise; +} + +export function CardChecklists({ + checklists, + addChecklist, + removeChecklist, + addCheckItem, + toggleCheckItem, + editCheckItem, + removeCheckItem, +}: CardChecklistsProps) { + const { openModal } = useModal(); + + const handleAddChecklist = () => { + openModal((props) => ( + { + const checklistCount = checklists?.length || 0; + return await addChecklist(name, checklistCount); + }} + onClose={props.onClose} + /> + )); + }; + + const handleDeleteChecklist = (checklist: any) => { + openModal((props) => ( + { + 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) => ( + { + 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 ( +
+
+

+ + + + Checklists +

+ +
+ {checklists && checklists.length > 0 ? ( +
+ {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 ( +
+
+
+

{checklist.name}

+
+
+
+
+ + {completedCount}/{totalCount} + +
+
+ +
+
+ {checklist.items && checklist.items.length > 0 ? ( + checklist.items.map((item: any) => ( +
+ handleToggleCheckItem(item)} + className="w-5 h-5 rounded cursor-pointer" + /> + handleToggleCheckItem(item)} + className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`} + > + {item.name} + +
+ + +
+
+ )) + ) : ( +

No items yet

+ )} + +
+
+ ); + })} +
+ ) : ( +

No checklists yet. Add one to get started!

+ )} +
+ ); +} diff --git a/frontend/src/components/CardLabels.tsx b/frontend/src/components/CardLabels.tsx new file mode 100644 index 0000000..8885e27 --- /dev/null +++ b/frontend/src/components/CardLabels.tsx @@ -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; + addLabelToCard: (labelId: number) => Promise; + removeLabelFromCard: (labelId: number) => Promise; + refetchLabels: () => Promise; + refetchCard: () => Promise; +} + +export function CardLabels({ + labels, + cardLabels, + addLabel, + addLabelToCard, + removeLabelFromCard, + refetchLabels, + refetchCard, +}: CardLabelsProps) { + const { openModal } = useModal(); + + const handleCreateLabel = () => { + openModal((props) => ( + { + 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 ( +
+
+

+ + + + Labels +

+ +
+
+ {cardLabels && cardLabels.length > 0 ? ( + cardLabels.map((label) => ( + + {label.name} + + )) + ) : ( +

No labels added to this card

+ )} +
+
+ ); +} diff --git a/frontend/src/components/CardPreviewModal.tsx b/frontend/src/components/CardPreviewModal.tsx new file mode 100644 index 0000000..5e3826f --- /dev/null +++ b/frontend/src/components/CardPreviewModal.tsx @@ -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 ( +
+
+ + {card.name} + + +
+ + {/* Labels */} + {card.labels && card.labels.length > 0 && ( +
+
+ {card.labels.map((label) => ( + + {label.name} + + ))} +
+
+ )} + + {/* Description */} + {card.description && ( +
+

Description

+

{card.description}

+
+ )} + + {/* Checklists */} + {card.checklists && card.checklists.length > 0 && ( +
+

Checklists

+
+ {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 ( +
+
+

{checklist.name}

+ + {completedItems}/{totalItems} + +
+
+
+
+ {/* Checklist Items */} +
+ {checklist.items.map((item) => ( +
+
+ {item.state === 'complete' && ( + + + + )} +
+ + {item.name} + +
+ ))} +
+
+ ); + })} +
+
+ )} + + {/* Comments Preview */} + {card.comments && card.comments.length > 0 && ( +
+

Comments

+
+ {card.comments.slice(0, 3).map((comment) => ( +
+
+
+ {comment.user?.username.charAt(0).toUpperCase() || '?'} +
+
+

+ {comment.user?.username || 'Unknown'} +

+

+ {new Date(comment.created_at).toLocaleDateString()} +

+
+
+

{comment.text}

+
+ ))} + {card.comments.length > 3 && ( +

+ +{card.comments.length - 3} more comments +

+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/CreateChecklistModal.tsx b/frontend/src/components/CreateChecklistModal.tsx new file mode 100644 index 0000000..bbb66b3 --- /dev/null +++ b/frontend/src/components/CreateChecklistModal.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +interface CreateChecklistModalProps { + onSave: (name: string) => Promise; + 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 ( +
+

Add Checklist

+
+
+ + 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 + /> +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/CreateLabelModal.tsx b/frontend/src/components/CreateLabelModal.tsx new file mode 100644 index 0000000..1ea45d8 --- /dev/null +++ b/frontend/src/components/CreateLabelModal.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; + +interface CreateLabelModalProps { + onSave: (name: string, color: string) => Promise; + 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 ( +
+

Create Label

+
+
+ + 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 + /> +
+
+ +
+ {COLORS.map((colorOption) => ( +
+
+
+ +
+ + {name || 'Label Preview'} + +
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/DeleteChecklistModal.tsx b/frontend/src/components/DeleteChecklistModal.tsx new file mode 100644 index 0000000..8e153bf --- /dev/null +++ b/frontend/src/components/DeleteChecklistModal.tsx @@ -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 ( +
+
+ + + + + +

Delete Checklist

+
+

+ Are you sure you want to delete + "{checklistName}"? This action + cannot be undone. +

+
+ + +
+
+ ); +} diff --git a/frontend/src/components/EditCheckItemModal.tsx b/frontend/src/components/EditCheckItemModal.tsx new file mode 100644 index 0000000..9e779e5 --- /dev/null +++ b/frontend/src/components/EditCheckItemModal.tsx @@ -0,0 +1,60 @@ +import { useState, useEffect } from 'react'; + +interface EditCheckItemModalProps { + itemName: string; + onSave: (name: string) => Promise; + 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 ( +
+

Edit Item

+
+
+ + 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 + /> +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/LabelDropdown.tsx b/frontend/src/components/LabelDropdown.tsx new file mode 100644 index 0000000..fab839b --- /dev/null +++ b/frontend/src/components/LabelDropdown.tsx @@ -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; + onRemoveLabel: (labelId: number) => Promise; + onCreateLabel: () => void; +} + +export function LabelDropdown({ + labels, + cardLabels, + onAddLabel, + onRemoveLabel, + onCreateLabel, +}: LabelDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(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 ( +
+ + + {isOpen && ( +
+
+

Labels

+ + {/* Add new label button */} + + + {/* Available labels */} + {availableLabels.length > 0 ? ( +
+

Available Labels

+ {availableLabels.map((label) => ( + + ))} +
+ ) : ( +

No labels available

+ )} + + {/* Current labels (can be removed) */} + {cardLabels.length > 0 && ( +
+

Current Labels

+ {cardLabels.map((label) => ( + + ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/kanban/DeleteListModal.tsx b/frontend/src/components/kanban/DeleteListModal.tsx new file mode 100644 index 0000000..a10889e --- /dev/null +++ b/frontend/src/components/kanban/DeleteListModal.tsx @@ -0,0 +1,81 @@ +import { ModalContentProps } from '../../types'; +import Trash2Icon from '../icons/Trash2Icon'; + +interface DeleteListModalProps extends ModalContentProps { + listName: string; + onDelete: () => Promise; +} + +export function DeleteListModal({ onClose, onDelete, listName }: DeleteListModalProps) { + const handleDelete = async () => { + try { + await onDelete(); + onClose(); + } catch (err) { + console.error('Failed to delete list:', err); + } + }; + + return ( +
+
+ + +
+ +
+
+
+
+ + + +
+
+
+

+ Delete "{listName}"? +

+

+ This will permanently delete the list and all cards in it. This action cannot be + undone. +

+
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/kanban/EditListModal.tsx b/frontend/src/components/kanban/EditListModal.tsx new file mode 100644 index 0000000..a6d3452 --- /dev/null +++ b/frontend/src/components/kanban/EditListModal.tsx @@ -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; + +interface EditListModalProps extends ModalContentProps { + listName: string; + onSave: (name: string) => Promise; +} + +export function EditListModal({ onClose, onSave, listName }: EditListModalProps) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + 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 ( +
+
+ + +
+ +
+
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/kanban/KanbanCard.tsx b/frontend/src/components/kanban/KanbanCard.tsx index 3ad5740..30fbd1a 100644 --- a/frontend/src/components/kanban/KanbanCard.tsx +++ b/frontend/src/components/kanban/KanbanCard.tsx @@ -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 (
{ + // 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" > -

{card.name}

- - {card.description && ( -

{card.description}

+ {/* Labels */} + {hasLabels && ( +
+ {labels.slice(0, 3).map((label: any) => ( + + {label.name} + + ))} + {labels.length > 3 && ( + + +{labels.length - 3} + + )} +
)} -
- #{card.id_short} +

+ e.stopPropagation()} + className="hover:underline" + > + {card.name} + +

+ + {card.description && ( +

{card.description}

+ )} + + {/* Badges */} +
+ {/* Comments Badge */} + {hasComments && ( +
+ + + + {commentCount} +
+ )} + + {/* Checklist Badge */} + {hasChecklists && ( +
+ + + + + {completedItems}/{totalItems} + +
+ )} + + {/* Due Date Badge */} {card.due && ( Due: {new Date(card.due).toLocaleDateString()} )} + + {/* Card ID */} + #{card.id_short}
); diff --git a/frontend/src/components/kanban/KanbanColumn.tsx b/frontend/src/components/kanban/KanbanColumn.tsx index f2acb25..1fa86db 100644 --- a/frontend/src/components/kanban/KanbanColumn.tsx +++ b/frontend/src/components/kanban/KanbanColumn.tsx @@ -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; + onListEdit?: (name: string) => Promise; + onListDelete?: () => Promise; + 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) => ); }; + const handleEditList = () => { + if (!onListEdit) return; + openModal((props) => ( + { + await onListEdit(name); + }} + /> + )); + }; + + const handleDeleteList = () => { + if (!onListDelete) return; + openModal((props) => ( + { + await onListDelete(); + }} + /> + )); + }; + return (
-

- {list.name} - - {cards.length} +
+
+
+ {/* Drag Handle Icon */} +
+ + + + + + + + +
+

{list.name}

+
+
+ {onListEdit && ( + + )} + {onListDelete && ( + + )} +
+
+ + {cards.length} cards -

+
{cards.map((card) => ( - onCardClick(card)} /> + onOpenCardModal(card)} /> ))}
diff --git a/frontend/src/components/kanban/SortableKanbanColumn.tsx b/frontend/src/components/kanban/SortableKanbanColumn.tsx new file mode 100644 index 0000000..4fcf422 --- /dev/null +++ b/frontend/src/components/kanban/SortableKanbanColumn.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/src/hooks/useChecklistMutations.ts b/frontend/src/hooks/useChecklistMutations.ts new file mode 100644 index 0000000..8793e8d --- /dev/null +++ b/frontend/src/hooks/useChecklistMutations.ts @@ -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, + }; +} diff --git a/frontend/src/hooks/useLabelMutations.ts b/frontend/src/hooks/useLabelMutations.ts new file mode 100644 index 0000000..28e95f5 --- /dev/null +++ b/frontend/src/hooks/useLabelMutations.ts @@ -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, + }; +} diff --git a/frontend/src/hooks/useLabels.ts b/frontend/src/hooks/useLabels.ts new file mode 100644 index 0000000..6d6b9a0 --- /dev/null +++ b/frontend/src/hooks/useLabels.ts @@ -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([]); + const [error, setError] = useState(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, + }; +} diff --git a/frontend/src/hooks/useListMutations.ts b/frontend/src/hooks/useListMutations.ts index 5369144..d89ec25 100644 --- a/frontend/src/hooks/useListMutations.ts +++ b/frontend/src/hooks/useListMutations.ts @@ -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({ diff --git a/frontend/src/pages/BoardDetail.tsx b/frontend/src/pages/BoardDetail.tsx index a6f7fc5..12e532b 100644 --- a/frontend/src/pages/BoardDetail.tsx +++ b/frontend/src/pages/BoardDetail.tsx @@ -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(null); + const [activeList, setActiveList] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { @@ -39,18 +41,23 @@ 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 (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); + 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); + if (card) { + setActiveCard(card); + } } } }; @@ -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) => ); }; 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, { @@ -223,23 +267,37 @@ export function BoardDetail() { onDragOver={handleDragOver} onDragEnd={handleDragEnd} > -
- {board.lists.map((list) => ( - - ))} -
+ `COLUMN_${list.id}`)} + strategy={horizontalListSortingStrategy} + > +
+ {board.lists.map((list) => ( + handleEditList(list.id, name)} + onListDelete={() => handleDeleteList(list.id)} + /> + ))} +
+
{activeCard ? (

{activeCard.name}

+ ) : activeList ? ( +
+

{activeList.name}

+ + {activeList.cards.length} cards + +
) : null}
diff --git a/frontend/src/pages/CardDetail.tsx b/frontend/src/pages/CardDetail.tsx index a66804c..f212fac 100644 --- a/frontend/src/pages/CardDetail.tsx +++ b/frontend/src/pages/CardDetail.tsx @@ -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() {
{/* Labels Section */} - {card.labels && card.labels.length > 0 && ( -
-

- - - - Labels -

-
- {card.labels.map((label: any) => ( - - {label.name} - - ))} -
-
- )} + {/* Checklists Section */} - {card.checklists && card.checklists.length > 0 && ( -
-

- - - - Checklists -

-
- {card.checklists.map((checklist: any) => ( -
-

{checklist.name}

-
- {checklist.items && checklist.items.length > 0 ? ( - checklist.items.map((item: any) => ( -
- - - {item.name} - -
- )) - ) : ( -

No items yet

- )} -
-
- ))} -
-
- )} +