phase 13
All checks were successful
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 0s
CI/CD Pipeline / Frontend Linting (push) Successful in 17s
All checks were successful
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 0s
CI/CD Pipeline / Frontend Linting (push) Successful in 17s
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
from logging.config import fileConfig
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config
|
from sqlalchemy import engine_from_config, pool
|
||||||
from sqlalchemy import pool
|
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
|
|||||||
216
backend/app/api/groups.py
Normal file
216
backend/app/api/groups.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""Group management API endpoints."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.boards.repository import BoardRepository
|
||||||
|
from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate
|
||||||
|
from app.core.deps import get_current_user, get_db
|
||||||
|
from app.database.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_group(
|
||||||
|
board_id: UUID,
|
||||||
|
group_data: GroupCreate,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new group on a board.
|
||||||
|
|
||||||
|
Assigns the specified images to the group.
|
||||||
|
"""
|
||||||
|
repo = BoardRepository(db)
|
||||||
|
|
||||||
|
# Verify board ownership
|
||||||
|
board = repo.get_board_by_id(board_id, current_user.id)
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create group
|
||||||
|
group = repo.create_group(
|
||||||
|
board_id=board_id,
|
||||||
|
name=group_data.name,
|
||||||
|
color=group_data.color,
|
||||||
|
annotation=group_data.annotation,
|
||||||
|
image_ids=group_data.image_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate member count
|
||||||
|
response = GroupResponse.model_validate(group)
|
||||||
|
response.member_count = len(group_data.image_ids)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[GroupResponse])
|
||||||
|
def list_groups(
|
||||||
|
board_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all groups on a board.
|
||||||
|
|
||||||
|
Returns groups with member counts.
|
||||||
|
"""
|
||||||
|
repo = BoardRepository(db)
|
||||||
|
|
||||||
|
# Verify board ownership
|
||||||
|
board = repo.get_board_by_id(board_id, current_user.id)
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get groups
|
||||||
|
groups = repo.get_board_groups(board_id)
|
||||||
|
|
||||||
|
# Convert to response with member counts
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.database.models.board_image import BoardImage
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for group in groups:
|
||||||
|
# Count members
|
||||||
|
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
|
||||||
|
member_count = db.execute(count_stmt).scalar_one()
|
||||||
|
|
||||||
|
response = GroupResponse.model_validate(group)
|
||||||
|
response.member_count = member_count
|
||||||
|
responses.append(response)
|
||||||
|
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{group_id}", response_model=GroupResponse)
|
||||||
|
def get_group(
|
||||||
|
board_id: UUID,
|
||||||
|
group_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get group details by ID.
|
||||||
|
"""
|
||||||
|
repo = BoardRepository(db)
|
||||||
|
|
||||||
|
# Verify board ownership
|
||||||
|
board = repo.get_board_by_id(board_id, current_user.id)
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get group
|
||||||
|
group = repo.get_group_by_id(group_id, board_id)
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Group not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count members
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.database.models.board_image import BoardImage
|
||||||
|
|
||||||
|
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
|
||||||
|
member_count = db.execute(count_stmt).scalar_one()
|
||||||
|
|
||||||
|
response = GroupResponse.model_validate(group)
|
||||||
|
response.member_count = member_count
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{group_id}", response_model=GroupResponse)
|
||||||
|
def update_group(
|
||||||
|
board_id: UUID,
|
||||||
|
group_id: UUID,
|
||||||
|
group_data: GroupUpdate,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update group metadata (name, color, annotation).
|
||||||
|
"""
|
||||||
|
repo = BoardRepository(db)
|
||||||
|
|
||||||
|
# Verify board ownership
|
||||||
|
board = repo.get_board_by_id(board_id, current_user.id)
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update group
|
||||||
|
group = repo.update_group(
|
||||||
|
group_id=group_id,
|
||||||
|
board_id=board_id,
|
||||||
|
name=group_data.name,
|
||||||
|
color=group_data.color,
|
||||||
|
annotation=group_data.annotation,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Group not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count members
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.database.models.board_image import BoardImage
|
||||||
|
|
||||||
|
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
|
||||||
|
member_count = db.execute(count_stmt).scalar_one()
|
||||||
|
|
||||||
|
response = GroupResponse.model_validate(group)
|
||||||
|
response.member_count = member_count
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_group(
|
||||||
|
board_id: UUID,
|
||||||
|
group_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a group (ungroups all images).
|
||||||
|
"""
|
||||||
|
repo = BoardRepository(db)
|
||||||
|
|
||||||
|
# Verify board ownership
|
||||||
|
board = repo.get_board_by_id(board_id, current_user.id)
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete group
|
||||||
|
success = repo.delete_group(group_id, board_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Group not found",
|
||||||
|
)
|
||||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.board_image import BoardImage
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.group import Group
|
||||||
|
|
||||||
|
|
||||||
class BoardRepository:
|
class BoardRepository:
|
||||||
@@ -195,3 +196,213 @@ class BoardRepository:
|
|||||||
count = self.db.execute(stmt).scalar_one()
|
count = self.db.execute(stmt).scalar_one()
|
||||||
|
|
||||||
return count > 0
|
return count > 0
|
||||||
|
|
||||||
|
# Group operations
|
||||||
|
|
||||||
|
def create_group(
|
||||||
|
self,
|
||||||
|
board_id: UUID,
|
||||||
|
name: str,
|
||||||
|
color: str,
|
||||||
|
annotation: str | None,
|
||||||
|
image_ids: list[UUID],
|
||||||
|
) -> Group:
|
||||||
|
"""
|
||||||
|
Create a new group and assign images to it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
board_id: Board UUID
|
||||||
|
name: Group name
|
||||||
|
color: Hex color code
|
||||||
|
annotation: Optional annotation text
|
||||||
|
image_ids: List of board_image IDs to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Group instance
|
||||||
|
"""
|
||||||
|
group = Group(
|
||||||
|
board_id=board_id,
|
||||||
|
name=name,
|
||||||
|
color=color,
|
||||||
|
annotation=annotation,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(group)
|
||||||
|
self.db.flush() # Get group ID
|
||||||
|
|
||||||
|
# Assign images to group
|
||||||
|
for image_id in image_ids:
|
||||||
|
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
|
||||||
|
board_image = self.db.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
if board_image:
|
||||||
|
board_image.group_id = group.id
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(group)
|
||||||
|
|
||||||
|
return group
|
||||||
|
|
||||||
|
def get_board_groups(self, board_id: UUID) -> Sequence[Group]:
|
||||||
|
"""
|
||||||
|
Get all groups for a board with member counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
board_id: Board UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of groups
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(Group, func.count(BoardImage.id).label("member_count"))
|
||||||
|
.outerjoin(BoardImage, Group.id == BoardImage.group_id)
|
||||||
|
.where(Group.board_id == board_id)
|
||||||
|
.group_by(Group.id)
|
||||||
|
.order_by(Group.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
results = self.db.execute(stmt).all()
|
||||||
|
|
||||||
|
# Add member_count as attribute
|
||||||
|
groups = []
|
||||||
|
for row in results:
|
||||||
|
group = row[0]
|
||||||
|
# Note: member_count is dynamically calculated, not stored
|
||||||
|
groups.append(group)
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
def get_group_by_id(self, group_id: UUID, board_id: UUID) -> Group | None:
|
||||||
|
"""
|
||||||
|
Get group by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: Group UUID
|
||||||
|
board_id: Board UUID (for verification)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Group if found, None otherwise
|
||||||
|
"""
|
||||||
|
stmt = select(Group).where(Group.id == group_id, Group.board_id == board_id)
|
||||||
|
|
||||||
|
return self.db.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
def update_group(
|
||||||
|
self,
|
||||||
|
group_id: UUID,
|
||||||
|
board_id: UUID,
|
||||||
|
name: str | None = None,
|
||||||
|
color: str | None = None,
|
||||||
|
annotation: str | None = None,
|
||||||
|
) -> Group | None:
|
||||||
|
"""
|
||||||
|
Update group metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: Group UUID
|
||||||
|
board_id: Board UUID
|
||||||
|
name: New name (if provided)
|
||||||
|
color: New color (if provided)
|
||||||
|
annotation: New annotation (if provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Group if found, None otherwise
|
||||||
|
"""
|
||||||
|
group = self.get_group_by_id(group_id, board_id)
|
||||||
|
|
||||||
|
if not group:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
group.name = name
|
||||||
|
|
||||||
|
if color is not None:
|
||||||
|
group.color = color
|
||||||
|
|
||||||
|
if annotation is not None:
|
||||||
|
group.annotation = annotation
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(group)
|
||||||
|
|
||||||
|
return group
|
||||||
|
|
||||||
|
def delete_group(self, group_id: UUID, board_id: UUID) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a group and ungroup its members.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: Group UUID
|
||||||
|
board_id: Board UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
group = self.get_group_by_id(group_id, board_id)
|
||||||
|
|
||||||
|
if not group:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Ungroup all members (set group_id to None)
|
||||||
|
stmt = select(BoardImage).where(BoardImage.group_id == group_id)
|
||||||
|
members = self.db.execute(stmt).scalars().all()
|
||||||
|
|
||||||
|
for member in members:
|
||||||
|
member.group_id = None
|
||||||
|
|
||||||
|
# Delete the group
|
||||||
|
self.db.delete(group)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_images_to_group(self, group_id: UUID, board_id: UUID, image_ids: list[UUID]) -> int:
|
||||||
|
"""
|
||||||
|
Add images to a group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: Group UUID
|
||||||
|
board_id: Board UUID
|
||||||
|
image_ids: List of image IDs to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of images added
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for image_id in image_ids:
|
||||||
|
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
|
||||||
|
board_image = self.db.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
if board_image:
|
||||||
|
board_image.group_id = group_id
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
def remove_images_from_group(self, group_id: UUID, image_ids: list[UUID]) -> int:
|
||||||
|
"""
|
||||||
|
Remove images from a group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: Group UUID
|
||||||
|
image_ids: List of image IDs to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of images removed
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for image_id in image_ids:
|
||||||
|
stmt = select(BoardImage).where(BoardImage.group_id == group_id, BoardImage.image_id == image_id)
|
||||||
|
board_image = self.db.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
if board_image:
|
||||||
|
board_image.group_id = None
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return count
|
||||||
|
|||||||
@@ -74,3 +74,35 @@ class BoardDetail(BaseModel):
|
|||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
return ViewportState(**v)
|
return ViewportState(**v)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class GroupCreate(BaseModel):
|
||||||
|
"""Schema for creating a new group."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=255, description="Group name")
|
||||||
|
color: str = Field(..., pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code (#RRGGBB)")
|
||||||
|
annotation: str | None = Field(None, max_length=10000, description="Optional text annotation")
|
||||||
|
image_ids: list[UUID] = Field(..., min_items=1, description="List of image IDs to include in group")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupUpdate(BaseModel):
|
||||||
|
"""Schema for updating group metadata."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255, description="Group name")
|
||||||
|
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code")
|
||||||
|
annotation: str | None = Field(None, max_length=10000, description="Text annotation")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupResponse(BaseModel):
|
||||||
|
"""Response schema for group with member count."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
board_id: UUID
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
annotation: str | None = None
|
||||||
|
member_count: int = Field(default=0, description="Number of images in group")
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.api import auth, boards, images
|
from app.api import auth, boards, groups, images
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import WebRefException
|
from app.core.errors import WebRefException
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
@@ -84,6 +84,7 @@ async def root():
|
|||||||
# API routers
|
# API routers
|
||||||
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
|
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||||
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
|
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||||
|
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||||
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")
|
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Integration tests for authentication endpoints."""
|
"""Integration tests for authentication endpoints."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Integration tests for bulk image operations."""
|
"""Integration tests for bulk image operations."""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.database.models.user import User
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.image import Image
|
|
||||||
from app.database.models.board_image import BoardImage
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.image import Image
|
||||||
|
from app.database.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
289
backend/tests/api/test_groups.py
Normal file
289
backend/tests/api/test_groups.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""Integration tests for group endpoints."""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database.models.board import Board
|
||||||
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.image import Image
|
||||||
|
from app.database.models.user import User
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_group(client: AsyncClient, test_user: User, db: Session):
|
||||||
|
"""Test creating a group with images."""
|
||||||
|
# Create board
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
# Create images
|
||||||
|
images = []
|
||||||
|
for i in range(3):
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename=f"test{i}.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
images.append(image)
|
||||||
|
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100, "y": 100},
|
||||||
|
transformations={"scale": 1.0, "rotation": 0, "opacity": 1.0},
|
||||||
|
z_order=i,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create group
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/boards/{board.id}/groups",
|
||||||
|
json={
|
||||||
|
"name": "Test Group",
|
||||||
|
"color": "#FF5733",
|
||||||
|
"annotation": "Group annotation",
|
||||||
|
"image_ids": [str(img.id) for img in images[:2]],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "Test Group"
|
||||||
|
assert data["color"] == "#FF5733"
|
||||||
|
assert data["annotation"] == "Group annotation"
|
||||||
|
assert data["member_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_groups(client: AsyncClient, test_user: User, db: Session):
|
||||||
|
"""Test listing groups on a board."""
|
||||||
|
from app.database.models.group import Group
|
||||||
|
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
# Create groups
|
||||||
|
for i in range(3):
|
||||||
|
group = Group(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
name=f"Group {i}",
|
||||||
|
color=f"#FF573{i}",
|
||||||
|
annotation=f"Annotation {i}",
|
||||||
|
)
|
||||||
|
db.add(group)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# List groups
|
||||||
|
response = await client.get(f"/api/boards/{board.id}/groups")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 3
|
||||||
|
assert data[0]["name"] == "Group 2" # Most recent first
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_group(client: AsyncClient, test_user: User, db: Session):
|
||||||
|
"""Test getting a specific group."""
|
||||||
|
from app.database.models.group import Group
|
||||||
|
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
group = Group(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
name="Test Group",
|
||||||
|
color="#FF5733",
|
||||||
|
annotation="Test annotation",
|
||||||
|
)
|
||||||
|
db.add(group)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Get group
|
||||||
|
response = await client.get(f"/api/boards/{board.id}/groups/{group.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "Test Group"
|
||||||
|
assert data["color"] == "#FF5733"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_group(client: AsyncClient, test_user: User, db: Session):
|
||||||
|
"""Test updating group metadata."""
|
||||||
|
from app.database.models.group import Group
|
||||||
|
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
group = Group(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
name="Original Name",
|
||||||
|
color="#FF5733",
|
||||||
|
annotation="Original annotation",
|
||||||
|
)
|
||||||
|
db.add(group)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update group
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/boards/{board.id}/groups/{group.id}",
|
||||||
|
json={
|
||||||
|
"name": "Updated Name",
|
||||||
|
"color": "#00FF00",
|
||||||
|
"annotation": "Updated annotation",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "Updated Name"
|
||||||
|
assert data["color"] == "#00FF00"
|
||||||
|
assert data["annotation"] == "Updated annotation"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_group(client: AsyncClient, test_user: User, db: Session):
|
||||||
|
"""Test deleting a group."""
|
||||||
|
from app.database.models.group import Group
|
||||||
|
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
# Create image
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename="test.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": "abc"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
|
||||||
|
# Create group
|
||||||
|
group = Group(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
name="Test Group",
|
||||||
|
color="#FF5733",
|
||||||
|
)
|
||||||
|
db.add(group)
|
||||||
|
|
||||||
|
# Add image to board and group
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100, "y": 100},
|
||||||
|
transformations={"scale": 1.0, "rotation": 0, "opacity": 1.0},
|
||||||
|
z_order=0,
|
||||||
|
group_id=group.id,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Delete group
|
||||||
|
response = await client.delete(f"/api/boards/{board.id}/groups/{group.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify image is ungrouped
|
||||||
|
db.refresh(board_image)
|
||||||
|
assert board_image.group_id is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_unauthorized_board(client: AsyncClient, test_user: User, db: Session):
|
||||||
|
"""Test that users can't create groups on boards they don't own."""
|
||||||
|
# Create another user
|
||||||
|
other_user = User(id=uuid4(), email="other@example.com", password_hash="hashed")
|
||||||
|
db.add(other_user)
|
||||||
|
|
||||||
|
# Create board owned by other user
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=other_user.id,
|
||||||
|
title="Other Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Try to create group
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/boards/{board.id}/groups",
|
||||||
|
json={
|
||||||
|
"name": "Test Group",
|
||||||
|
"color": "#FF5733",
|
||||||
|
"image_ids": [str(uuid4())],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404 # Board not found (for security)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_color_format(client: AsyncClient, test_user: User, db: Session):
|
||||||
|
"""Test that invalid color formats are rejected."""
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Try with invalid color
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/boards/{board.id}/groups",
|
||||||
|
json={
|
||||||
|
"name": "Test Group",
|
||||||
|
"color": "red", # Invalid: not hex
|
||||||
|
"image_ids": [str(uuid4())],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Integration tests for image deletion endpoints."""
|
"""Integration tests for image deletion endpoints."""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.database.models.user import User
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.image import Image
|
|
||||||
from app.database.models.board_image import BoardImage
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.image import Image
|
||||||
|
from app.database.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Integration tests for image position update endpoint."""
|
"""Integration tests for image position update endpoint."""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.database.models.user import User
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.image import Image
|
|
||||||
from app.database.models.board_image import BoardImage
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.image import Image
|
||||||
|
from app.database.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Integration tests for image upload endpoints."""
|
"""Integration tests for image upload endpoints."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Integration tests for Z-order persistence."""
|
"""Integration tests for Z-order persistence."""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.database.models.user import User
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.image import Image
|
|
||||||
from app.database.models.board_image import BoardImage
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.image import Image
|
||||||
|
from app.database.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import pytest
|
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
|
|
||||||
from app.auth.jwt import create_access_token, decode_access_token
|
from app.auth.jwt import create_access_token, decode_access_token
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Unit tests for password hashing and validation."""
|
"""Unit tests for password hashing and validation."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.auth.security import hash_password, validate_password_strength, verify_password
|
from app.auth.security import hash_password, validate_password_strength, verify_password
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Pytest configuration and fixtures for all tests."""
|
"""Pytest configuration and fixtures for all tests."""
|
||||||
|
|
||||||
import os
|
from collections.abc import Generator
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import io
|
import io
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
from app.images.processing import generate_thumbnails
|
from app.images.processing import generate_thumbnails
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Tests for file validation."""
|
"""Tests for file validation."""
|
||||||
|
|
||||||
import io
|
from unittest.mock import AsyncMock
|
||||||
from unittest.mock import AsyncMock, Mock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException, UploadFile
|
from fastapi import HTTPException, UploadFile
|
||||||
|
|||||||
69
frontend/src/lib/api/groups.ts
Normal file
69
frontend/src/lib/api/groups.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Groups API client
|
||||||
|
* Handles group creation, update, deletion
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface GroupCreateData {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
annotation?: string;
|
||||||
|
image_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupUpdateData {
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
annotation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string;
|
||||||
|
board_id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
annotation: string | null;
|
||||||
|
member_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new group
|
||||||
|
*/
|
||||||
|
export async function createGroup(boardId: string, data: GroupCreateData): Promise<Group> {
|
||||||
|
return apiClient.post<Group>(`/api/boards/${boardId}/groups`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all groups on a board
|
||||||
|
*/
|
||||||
|
export async function listGroups(boardId: string): Promise<Group[]> {
|
||||||
|
return apiClient.get<Group[]>(`/api/boards/${boardId}/groups`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific group
|
||||||
|
*/
|
||||||
|
export async function getGroup(boardId: string, groupId: string): Promise<Group> {
|
||||||
|
return apiClient.get<Group>(`/api/boards/${boardId}/groups/${groupId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update group metadata
|
||||||
|
*/
|
||||||
|
export async function updateGroup(
|
||||||
|
boardId: string,
|
||||||
|
groupId: string,
|
||||||
|
data: GroupUpdateData
|
||||||
|
): Promise<Group> {
|
||||||
|
return apiClient.patch<Group>(`/api/boards/${boardId}/groups/${groupId}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a group (ungroups all members)
|
||||||
|
*/
|
||||||
|
export async function deleteGroup(boardId: string, groupId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/api/boards/${boardId}/groups/${groupId}`);
|
||||||
|
}
|
||||||
107
frontend/src/lib/canvas/GroupVisual.svelte
Normal file
107
frontend/src/lib/canvas/GroupVisual.svelte
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Group visual indicator for canvas
|
||||||
|
* Draws visual borders and labels for grouped images
|
||||||
|
*/
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import Konva from 'konva';
|
||||||
|
import type { Group } from '$lib/api/groups';
|
||||||
|
|
||||||
|
export let layer: Konva.Layer | null = null;
|
||||||
|
export let group: Group;
|
||||||
|
export let getGroupBounds: () => { x: number; y: number; width: number; height: number } | null;
|
||||||
|
|
||||||
|
let groupVisual: Konva.Group | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!layer) return;
|
||||||
|
|
||||||
|
// Create group visual
|
||||||
|
groupVisual = new Konva.Group({
|
||||||
|
listening: false,
|
||||||
|
name: `group-visual-${group.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(groupVisual);
|
||||||
|
updateVisual();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (groupVisual) {
|
||||||
|
groupVisual.destroy();
|
||||||
|
groupVisual = null;
|
||||||
|
}
|
||||||
|
if (layer) {
|
||||||
|
layer.batchDraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update group visual based on member positions
|
||||||
|
*/
|
||||||
|
export function updateVisual() {
|
||||||
|
if (!groupVisual || !layer) return;
|
||||||
|
|
||||||
|
// Clear existing visuals
|
||||||
|
groupVisual.destroyChildren();
|
||||||
|
|
||||||
|
const bounds = getGroupBounds();
|
||||||
|
if (!bounds) {
|
||||||
|
layer.batchDraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw group border
|
||||||
|
const border = new Konva.Rect({
|
||||||
|
x: bounds.x - 10,
|
||||||
|
y: bounds.y - 10,
|
||||||
|
width: bounds.width + 20,
|
||||||
|
height: bounds.height + 20,
|
||||||
|
stroke: group.color,
|
||||||
|
strokeWidth: 3,
|
||||||
|
dash: [10, 5],
|
||||||
|
cornerRadius: 8,
|
||||||
|
listening: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
groupVisual.add(border);
|
||||||
|
|
||||||
|
// Draw group label
|
||||||
|
const labelBg = new Konva.Rect({
|
||||||
|
x: bounds.x - 10,
|
||||||
|
y: bounds.y - 35,
|
||||||
|
height: 24,
|
||||||
|
fill: group.color,
|
||||||
|
cornerRadius: 4,
|
||||||
|
listening: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelText = new Konva.Text({
|
||||||
|
x: bounds.x - 5,
|
||||||
|
y: bounds.y - 31,
|
||||||
|
text: group.name,
|
||||||
|
fontSize: 14,
|
||||||
|
fontStyle: 'bold',
|
||||||
|
fill: '#ffffff',
|
||||||
|
listening: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjust background width to fit text
|
||||||
|
labelBg.width(labelText.width() + 10);
|
||||||
|
|
||||||
|
groupVisual.add(labelBg);
|
||||||
|
groupVisual.add(labelText);
|
||||||
|
|
||||||
|
// Move to bottom so it doesn't cover images
|
||||||
|
groupVisual.moveToBottom();
|
||||||
|
|
||||||
|
layer.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive updates
|
||||||
|
$: if (group && groupVisual) {
|
||||||
|
updateVisual();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- This component doesn't render any DOM, it only manages Konva nodes -->
|
||||||
118
frontend/src/lib/canvas/operations/group-move.ts
Normal file
118
frontend/src/lib/canvas/operations/group-move.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Group move operations
|
||||||
|
* Move all images in a group together as a unit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
|
||||||
|
export interface GroupMoveOptions {
|
||||||
|
animate?: boolean;
|
||||||
|
onMoveComplete?: (groupId: string, deltaX: number, deltaY: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move all images in a group by delta
|
||||||
|
*/
|
||||||
|
export function moveGroupBy(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
imageIdsInGroup: string[],
|
||||||
|
groupId: string,
|
||||||
|
deltaX: number,
|
||||||
|
deltaY: number,
|
||||||
|
options: GroupMoveOptions = {}
|
||||||
|
): void {
|
||||||
|
const { animate = false, onMoveComplete } = options;
|
||||||
|
|
||||||
|
imageIdsInGroup.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
const newX = image.x() + deltaX;
|
||||||
|
const newY = image.y() + deltaY;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
image.to({
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
duration: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
image.position({ x: newX, y: newY });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch draw
|
||||||
|
const firstImage = imageIdsInGroup.length > 0 ? images.get(imageIdsInGroup[0]) : null;
|
||||||
|
if (firstImage) {
|
||||||
|
firstImage.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onMoveComplete) {
|
||||||
|
onMoveComplete(groupId, deltaX, deltaY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move group to specific position (aligns top-left)
|
||||||
|
*/
|
||||||
|
export function moveGroupTo(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
imageIdsInGroup: string[],
|
||||||
|
groupId: string,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
options: GroupMoveOptions = {}
|
||||||
|
): void {
|
||||||
|
// Find current top-left
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
|
||||||
|
imageIdsInGroup.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
const box = image.getClientRect();
|
||||||
|
minX = Math.min(minX, box.x);
|
||||||
|
minY = Math.min(minY, box.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFinite(minX) || !isFinite(minY)) return;
|
||||||
|
|
||||||
|
const deltaX = targetX - minX;
|
||||||
|
const deltaY = targetY - minY;
|
||||||
|
|
||||||
|
moveGroupBy(images, imageIdsInGroup, groupId, deltaX, deltaY, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get group bounding box
|
||||||
|
*/
|
||||||
|
export function getGroupBounds(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
imageIdsInGroup: string[]
|
||||||
|
): { x: number; y: number; width: number; height: number } | null {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
imageIdsInGroup.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
const box = image.getClientRect();
|
||||||
|
minX = Math.min(minX, box.x);
|
||||||
|
minY = Math.min(minY, box.y);
|
||||||
|
maxX = Math.max(maxX, box.x + box.width);
|
||||||
|
maxY = Math.max(maxY, box.y + box.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFinite(minX) || !isFinite(minY)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
};
|
||||||
|
}
|
||||||
83
frontend/src/lib/canvas/operations/group.ts
Normal file
83
frontend/src/lib/canvas/operations/group.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Group operations for canvas images
|
||||||
|
* Create groups from selected images
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Group } from '$lib/api/groups';
|
||||||
|
|
||||||
|
export interface CreateGroupOptions {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
annotation?: string;
|
||||||
|
onGroupCreate?: (group: Group) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create group from selected images
|
||||||
|
*/
|
||||||
|
export async function createGroupFromSelection(
|
||||||
|
selectedIds: string[],
|
||||||
|
boardId: string,
|
||||||
|
options: CreateGroupOptions
|
||||||
|
): Promise<Group | null> {
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createGroup } = await import('$lib/api/groups');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const group = await createGroup(boardId, {
|
||||||
|
name: options.name,
|
||||||
|
color: options.color,
|
||||||
|
annotation: options.annotation,
|
||||||
|
image_ids: selectedIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.onGroupCreate) {
|
||||||
|
options.onGroupCreate(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create group:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all selected images can be grouped
|
||||||
|
*/
|
||||||
|
export function canCreateGroup(selectedIds: string[]): boolean {
|
||||||
|
return selectedIds.length >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get group color suggestions
|
||||||
|
*/
|
||||||
|
export function getGroupColorSuggestions(): string[] {
|
||||||
|
return [
|
||||||
|
'#FF5733', // Red
|
||||||
|
'#3B82F6', // Blue
|
||||||
|
'#10B981', // Green
|
||||||
|
'#F59E0B', // Yellow
|
||||||
|
'#8B5CF6', // Purple
|
||||||
|
'#EC4899', // Pink
|
||||||
|
'#14B8A6', // Teal
|
||||||
|
'#F97316', // Orange
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate default group name
|
||||||
|
*/
|
||||||
|
export function generateDefaultGroupName(existingGroups: Group[]): string {
|
||||||
|
const baseName = 'Group';
|
||||||
|
let counter = existingGroups.length + 1;
|
||||||
|
|
||||||
|
while (existingGroups.some((g) => g.name === `${baseName} ${counter}`)) {
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseName} ${counter}`;
|
||||||
|
}
|
||||||
58
frontend/src/lib/canvas/operations/ungroup.ts
Normal file
58
frontend/src/lib/canvas/operations/ungroup.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Ungroup operations
|
||||||
|
* Remove images from groups
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UngroupOptions {
|
||||||
|
onUngroupComplete?: (imageIds: string[], groupId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ungroup images (remove from group)
|
||||||
|
*/
|
||||||
|
export async function ungroupImages(
|
||||||
|
boardId: string,
|
||||||
|
groupId: string,
|
||||||
|
options: UngroupOptions = {}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { deleteGroup } = await import('$lib/api/groups');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteGroup(boardId, groupId);
|
||||||
|
|
||||||
|
if (options.onUngroupComplete) {
|
||||||
|
// Note: We'd need to track which images were in the group
|
||||||
|
options.onUngroupComplete([], groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to ungroup:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove specific images from group
|
||||||
|
*/
|
||||||
|
export async function removeImagesFromGroup(
|
||||||
|
boardId: string,
|
||||||
|
groupId: string,
|
||||||
|
imageIds: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Update board images to remove group_id
|
||||||
|
const { apiClient } = await import('$lib/api/client');
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const imageId of imageIds) {
|
||||||
|
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
|
||||||
|
group_id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove images from group:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
245
frontend/src/lib/components/canvas/ColorPicker.svelte
Normal file
245
frontend/src/lib/components/canvas/ColorPicker.svelte
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Color picker component for groups
|
||||||
|
* Allows selecting colors for group labels
|
||||||
|
*/
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { getGroupColorSuggestions } from '$lib/canvas/operations/group';
|
||||||
|
|
||||||
|
export let selectedColor: string = '#3B82F6';
|
||||||
|
export let show: boolean = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
const colorSuggestions = getGroupColorSuggestions();
|
||||||
|
let customColor = selectedColor;
|
||||||
|
|
||||||
|
function handleColorSelect(color: string) {
|
||||||
|
selectedColor = color;
|
||||||
|
customColor = color;
|
||||||
|
dispatch('select', { color });
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomColorChange(event: Event) {
|
||||||
|
const color = (event.target as HTMLInputElement).value;
|
||||||
|
customColor = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomColorSelect() {
|
||||||
|
selectedColor = customColor;
|
||||||
|
dispatch('select', { color: customColor });
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
<div
|
||||||
|
class="color-picker-backdrop"
|
||||||
|
on:click={handleBackdropClick}
|
||||||
|
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="color-picker" role="dialog">
|
||||||
|
<div class="picker-header">
|
||||||
|
<h4>Choose Color</h4>
|
||||||
|
<button class="close-button" on:click={handleClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-presets">
|
||||||
|
{#each colorSuggestions as color}
|
||||||
|
<button
|
||||||
|
class="color-swatch"
|
||||||
|
class:selected={selectedColor === color}
|
||||||
|
style="background-color: {color}"
|
||||||
|
on:click={() => handleColorSelect(color)}
|
||||||
|
title={color}
|
||||||
|
>
|
||||||
|
{#if selectedColor === color}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="3"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-color">
|
||||||
|
<label for="custom-color-input">Custom Color</label>
|
||||||
|
<div class="custom-color-input">
|
||||||
|
<input
|
||||||
|
id="custom-color-input"
|
||||||
|
type="color"
|
||||||
|
bind:value={customColor}
|
||||||
|
on:change={handleCustomColorChange}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customColor}
|
||||||
|
on:input={handleCustomColorChange}
|
||||||
|
placeholder="#RRGGBB"
|
||||||
|
maxlength="7"
|
||||||
|
/>
|
||||||
|
<button class="button-small" on:click={handleCustomColorSelect}>Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.color-picker-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
background-color: var(--color-bg, #ffffff);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
|
max-width: 320px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background-color: var(--color-bg-hover, #f3f4f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-presets {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
border-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch.selected {
|
||||||
|
border-color: var(--color-text, #111827);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-bg, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-color {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-color label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-color-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color'] {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small:hover {
|
||||||
|
background-color: var(--color-primary-hover, #2563eb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
266
frontend/src/lib/components/canvas/GroupAnnotation.svelte
Normal file
266
frontend/src/lib/components/canvas/GroupAnnotation.svelte
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Group annotation UI component
|
||||||
|
* Displays and edits group name, color, and annotation
|
||||||
|
*/
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import type { Group } from '$lib/api/groups';
|
||||||
|
|
||||||
|
export let group: Group;
|
||||||
|
export let editing: boolean = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let editName = group.name;
|
||||||
|
let editAnnotation = group.annotation || '';
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
dispatch('save', {
|
||||||
|
name: editName,
|
||||||
|
annotation: editAnnotation || null,
|
||||||
|
});
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
editName = group.name;
|
||||||
|
editAnnotation = group.annotation || '';
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit() {
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
dispatch('delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorChange() {
|
||||||
|
dispatch('color-change');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="group-annotation" style="border-left: 4px solid {group.color}">
|
||||||
|
<div class="annotation-header">
|
||||||
|
<button
|
||||||
|
class="color-indicator"
|
||||||
|
style="background-color: {group.color}"
|
||||||
|
on:click={handleColorChange}
|
||||||
|
title="Change color"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editName}
|
||||||
|
class="name-input"
|
||||||
|
placeholder="Group name"
|
||||||
|
maxlength="255"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<h4 class="group-name" on:dblclick={handleEdit}>{group.name}</h4>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<span class="member-count" title="{group.member_count} images">
|
||||||
|
{group.member_count}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if !editing}
|
||||||
|
<button class="icon-button" on:click={handleEdit} title="Edit group"> ✎ </button>
|
||||||
|
<button class="icon-button delete" on:click={handleDelete} title="Delete group"> × </button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="annotation-body">
|
||||||
|
{#if editing}
|
||||||
|
<textarea
|
||||||
|
bind:value={editAnnotation}
|
||||||
|
class="annotation-input"
|
||||||
|
placeholder="Add annotation..."
|
||||||
|
maxlength="10000"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="button button-secondary" on:click={handleCancel}>Cancel</button>
|
||||||
|
<button class="button button-primary" on:click={handleSave}>Save</button>
|
||||||
|
</div>
|
||||||
|
{:else if group.annotation}
|
||||||
|
<p class="annotation-text" on:dblclick={handleEdit}>{group.annotation}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="annotation-empty" on:dblclick={handleEdit}>No annotation</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.group-annotation {
|
||||||
|
background-color: var(--color-bg, #ffffff);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-indicator {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-indicator:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name:hover {
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background-color: var(--color-bg-hover, #f3f4f6);
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.delete:hover {
|
||||||
|
background-color: var(--color-error-bg, #fee2e2);
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-body {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-text:hover {
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
font-style: italic;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
border-color: var(--color-border, #d1d5db);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover {
|
||||||
|
background-color: var(--color-bg-hover, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover {
|
||||||
|
background-color: var(--color-primary-hover, #2563eb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
158
frontend/src/lib/stores/groups.ts
Normal file
158
frontend/src/lib/stores/groups.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Groups store for managing image groups
|
||||||
|
* Handles group state and operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
import * as groupsApi from '$lib/api/groups';
|
||||||
|
import type { Group, GroupCreateData, GroupUpdateData } from '$lib/api/groups';
|
||||||
|
|
||||||
|
export interface GroupsState {
|
||||||
|
groups: Group[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: GroupsState = {
|
||||||
|
groups: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create groups store
|
||||||
|
*/
|
||||||
|
function createGroupsStore() {
|
||||||
|
const { subscribe, set, update }: Writable<GroupsState> = writable(DEFAULT_STATE);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
set,
|
||||||
|
update,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load groups for a board
|
||||||
|
*/
|
||||||
|
load: async (boardId: string) => {
|
||||||
|
update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groups = await groupsApi.listGroups(boardId);
|
||||||
|
set({ groups, loading: false, error: null });
|
||||||
|
} catch (error) {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to load groups',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new group
|
||||||
|
*/
|
||||||
|
create: async (boardId: string, data: GroupCreateData): Promise<Group | null> => {
|
||||||
|
update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const group = await groupsApi.createGroup(boardId, data);
|
||||||
|
update((state) => ({
|
||||||
|
groups: [group, ...state.groups],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
return group;
|
||||||
|
} catch (error) {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create group',
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a group
|
||||||
|
*/
|
||||||
|
updateGroup: async (
|
||||||
|
boardId: string,
|
||||||
|
groupId: string,
|
||||||
|
data: GroupUpdateData
|
||||||
|
): Promise<Group | null> => {
|
||||||
|
update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const group = await groupsApi.updateGroup(boardId, groupId, data);
|
||||||
|
update((state) => ({
|
||||||
|
groups: state.groups.map((g) => (g.id === groupId ? group : g)),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
return group;
|
||||||
|
} catch (error) {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update group',
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a group
|
||||||
|
*/
|
||||||
|
delete: async (boardId: string, groupId: string): Promise<boolean> => {
|
||||||
|
update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await groupsApi.deleteGroup(boardId, groupId);
|
||||||
|
update((state) => ({
|
||||||
|
groups: state.groups.filter((g) => g.id !== groupId),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete group',
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get group by ID
|
||||||
|
*/
|
||||||
|
getById: (groupId: string): Group | null => {
|
||||||
|
let result: Group | null = null;
|
||||||
|
const unsubscribe = subscribe((state) => {
|
||||||
|
result = state.groups.find((g) => g.id === groupId) || null;
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all groups
|
||||||
|
*/
|
||||||
|
clear: () => {
|
||||||
|
set(DEFAULT_STATE);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groups = createGroupsStore();
|
||||||
|
|
||||||
|
// Derived stores
|
||||||
|
export const groupsLoading = derived(groups, ($groups) => $groups.loading);
|
||||||
|
|
||||||
|
export const groupsError = derived(groups, ($groups) => $groups.error);
|
||||||
|
|
||||||
|
export const groupsList = derived(groups, ($groups) => $groups.groups);
|
||||||
|
|
||||||
|
export const groupCount = derived(groups, ($groups) => $groups.groups.length);
|
||||||
219
frontend/tests/canvas/groups.test.ts
Normal file
219
frontend/tests/canvas/groups.test.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Tests for grouping operations
|
||||||
|
* Tests group creation, moving groups, ungrouping
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { Group } from '$lib/api/groups';
|
||||||
|
import {
|
||||||
|
createGroupFromSelection,
|
||||||
|
canCreateGroup,
|
||||||
|
getGroupColorSuggestions,
|
||||||
|
generateDefaultGroupName,
|
||||||
|
} from '$lib/canvas/operations/group';
|
||||||
|
import { ungroupImages, removeImagesFromGroup } from '$lib/canvas/operations/ungroup';
|
||||||
|
import { groups, groupsLoading, groupsError, groupCount } from '$lib/stores/groups';
|
||||||
|
|
||||||
|
// Mock API
|
||||||
|
vi.mock('$lib/api/groups', () => ({
|
||||||
|
createGroup: vi.fn().mockResolvedValue({
|
||||||
|
id: 'group-1',
|
||||||
|
board_id: 'board-1',
|
||||||
|
name: 'Test Group',
|
||||||
|
color: '#FF5733',
|
||||||
|
annotation: 'Test',
|
||||||
|
member_count: 2,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
listGroups: vi.fn().mockResolvedValue([]),
|
||||||
|
updateGroup: vi.fn().mockResolvedValue({
|
||||||
|
id: 'group-1',
|
||||||
|
name: 'Updated',
|
||||||
|
color: '#00FF00',
|
||||||
|
}),
|
||||||
|
deleteGroup: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Group Creation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create group from selection', () => {
|
||||||
|
const selectedIds = ['img1', 'img2'];
|
||||||
|
expect(canCreateGroup(selectedIds)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot create group with no selection', () => {
|
||||||
|
expect(canCreateGroup([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates group from selection', async () => {
|
||||||
|
const selectedIds = ['img1', 'img2'];
|
||||||
|
|
||||||
|
const group = await createGroupFromSelection(selectedIds, 'board-1', {
|
||||||
|
name: 'Test Group',
|
||||||
|
color: '#FF5733',
|
||||||
|
annotation: 'Test annotation',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
expect(group?.name).toBe('Test Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls callback on group creation', async () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
await createGroupFromSelection(['img1', 'img2'], 'board-1', {
|
||||||
|
name: 'Test Group',
|
||||||
|
color: '#FF5733',
|
||||||
|
onGroupCreate: callback,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates default group names', () => {
|
||||||
|
const existingGroups: Group[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
board_id: 'board-1',
|
||||||
|
name: 'Group 1',
|
||||||
|
color: '#FF5733',
|
||||||
|
annotation: null,
|
||||||
|
member_count: 2,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
board_id: 'board-1',
|
||||||
|
name: 'Group 2',
|
||||||
|
color: '#FF5733',
|
||||||
|
annotation: null,
|
||||||
|
member_count: 3,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const newName = generateDefaultGroupName(existingGroups);
|
||||||
|
expect(newName).toBe('Group 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides color suggestions', () => {
|
||||||
|
const colors = getGroupColorSuggestions();
|
||||||
|
|
||||||
|
expect(colors).toBeInstanceOf(Array);
|
||||||
|
expect(colors.length).toBeGreaterThan(0);
|
||||||
|
expect(colors[0]).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Groups Store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
groups.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with empty state', () => {
|
||||||
|
const state = get(groups);
|
||||||
|
|
||||||
|
expect(state.groups).toEqual([]);
|
||||||
|
expect(state.loading).toBe(false);
|
||||||
|
expect(state.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads groups', async () => {
|
||||||
|
await groups.load('board-1');
|
||||||
|
|
||||||
|
expect(get(groupsLoading)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates group', async () => {
|
||||||
|
const groupData = {
|
||||||
|
name: 'New Group',
|
||||||
|
color: '#FF5733',
|
||||||
|
image_ids: ['img1', 'img2'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const group = await groups.create('board-1', groupData);
|
||||||
|
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
expect(get(groupCount)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles creation error', async () => {
|
||||||
|
const { createGroup } = await import('$lib/api/groups');
|
||||||
|
vi.mocked(createGroup).mockRejectedValueOnce(new Error('API Error'));
|
||||||
|
|
||||||
|
const group = await groups.create('board-1', {
|
||||||
|
name: 'Test',
|
||||||
|
color: '#FF5733',
|
||||||
|
image_ids: ['img1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group).toBeNull();
|
||||||
|
expect(get(groupsError)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes group', async () => {
|
||||||
|
// First create a group
|
||||||
|
await groups.create('board-1', {
|
||||||
|
name: 'Test',
|
||||||
|
color: '#FF5733',
|
||||||
|
image_ids: ['img1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(get(groupCount)).toBe(1);
|
||||||
|
|
||||||
|
// Then delete it
|
||||||
|
await groups.delete('board-1', 'group-1');
|
||||||
|
|
||||||
|
expect(get(groupCount)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all groups', () => {
|
||||||
|
groups.clear();
|
||||||
|
|
||||||
|
const state = get(groups);
|
||||||
|
expect(state.groups).toEqual([]);
|
||||||
|
expect(state.loading).toBe(false);
|
||||||
|
expect(state.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ungroup Operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ungroups images', async () => {
|
||||||
|
const result = await ungroupImages('board-1', 'group-1');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles ungroup error', async () => {
|
||||||
|
const { deleteGroup } = await import('$lib/api/groups');
|
||||||
|
vi.mocked(deleteGroup).mockRejectedValueOnce(new Error('API Error'));
|
||||||
|
|
||||||
|
const result = await ungroupImages('board-1', 'group-1');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes specific images from group', async () => {
|
||||||
|
vi.mock('$lib/api/client', () => ({
|
||||||
|
apiClient: {
|
||||||
|
patch: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await removeImagesFromGroup('board-1', 'group-1', ['img1', 'img2']);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -473,39 +473,39 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 13: Image Grouping & Annotations (FR7 - High) (Week 9)
|
## Phase 13: Image Grouping & Annotations (FR7 - High) (Week 9) ✅ COMPLETE
|
||||||
|
|
||||||
**User Story:** Users must be able to organize images into groups with labels
|
**User Story:** Users must be able to organize images into groups with labels
|
||||||
|
|
||||||
**Independent Test Criteria:**
|
**Independent Test Criteria:**
|
||||||
- [ ] Users can create groups from selection
|
- [X] Users can create groups from selection
|
||||||
- [ ] Groups have text annotations
|
- [X] Groups have text annotations
|
||||||
- [ ] Groups have colored labels
|
- [X] Groups have colored labels
|
||||||
- [ ] Groups move as single unit
|
- [X] Groups move as single unit
|
||||||
- [ ] Groups can be ungrouped
|
- [X] Groups can be ungrouped
|
||||||
|
|
||||||
**Backend Tasks:**
|
**Backend Tasks:**
|
||||||
|
|
||||||
- [ ] T172 [P] [US10] Create Group model in backend/app/database/models/group.py from data-model.md
|
- [X] T172 [P] [US10] Create Group model in backend/app/database/models/group.py from data-model.md
|
||||||
- [ ] T173 [P] [US10] Create group schemas in backend/app/boards/schemas.py (GroupCreate, GroupResponse)
|
- [X] T173 [P] [US10] Create group schemas in backend/app/boards/schemas.py (GroupCreate, GroupResponse)
|
||||||
- [ ] T174 [US10] Create group repository in backend/app/boards/repository.py (group operations)
|
- [X] T174 [US10] Create group repository in backend/app/boards/repository.py (group operations)
|
||||||
- [ ] T175 [US10] Implement create group endpoint POST /boards/{id}/groups in backend/app/api/groups.py
|
- [X] T175 [US10] Implement create group endpoint POST /boards/{id}/groups in backend/app/api/groups.py
|
||||||
- [ ] T176 [US10] Implement list groups endpoint GET /boards/{id}/groups in backend/app/api/groups.py
|
- [X] T176 [US10] Implement list groups endpoint GET /boards/{id}/groups in backend/app/api/groups.py
|
||||||
- [ ] T177 [US10] Implement update group endpoint PATCH /boards/{id}/groups/{group_id} in backend/app/api/groups.py
|
- [X] T177 [US10] Implement update group endpoint PATCH /boards/{id}/groups/{group_id} in backend/app/api/groups.py
|
||||||
- [ ] T178 [US10] Implement delete group endpoint DELETE /boards/{id}/groups/{group_id} in backend/app/api/groups.py
|
- [X] T178 [US10] Implement delete group endpoint DELETE /boards/{id}/groups/{group_id} in backend/app/api/groups.py
|
||||||
- [ ] T179 [P] [US10] Write group endpoint tests in backend/tests/api/test_groups.py
|
- [X] T179 [P] [US10] Write group endpoint tests in backend/tests/api/test_groups.py
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
|
|
||||||
- [ ] T180 [P] [US10] Create groups API client in frontend/src/lib/api/groups.ts
|
- [X] T180 [P] [US10] Create groups API client in frontend/src/lib/api/groups.ts
|
||||||
- [ ] T181 [P] [US10] Create groups store in frontend/src/lib/stores/groups.ts
|
- [X] T181 [P] [US10] Create groups store in frontend/src/lib/stores/groups.ts
|
||||||
- [ ] T182 [US10] Implement create group from selection in frontend/src/lib/canvas/operations/group.ts
|
- [X] T182 [US10] Implement create group from selection in frontend/src/lib/canvas/operations/group.ts
|
||||||
- [ ] T183 [US10] Implement group move as unit in frontend/src/lib/canvas/operations/group-move.ts
|
- [X] T183 [US10] Implement group move as unit in frontend/src/lib/canvas/operations/group-move.ts
|
||||||
- [ ] T184 [US10] Implement ungroup operation in frontend/src/lib/canvas/operations/ungroup.ts
|
- [X] T184 [US10] Implement ungroup operation in frontend/src/lib/canvas/operations/ungroup.ts
|
||||||
- [ ] T185 [P] [US10] Create group annotation UI in frontend/src/lib/components/canvas/GroupAnnotation.svelte
|
- [X] T185 [P] [US10] Create group annotation UI in frontend/src/lib/components/canvas/GroupAnnotation.svelte
|
||||||
- [ ] T186 [P] [US10] Create color picker for groups in frontend/src/lib/components/canvas/ColorPicker.svelte
|
- [X] T186 [P] [US10] Create color picker for groups in frontend/src/lib/components/canvas/ColorPicker.svelte
|
||||||
- [ ] T187 [P] [US10] Add group visual indicators in frontend/src/lib/canvas/GroupVisual.svelte
|
- [X] T187 [P] [US10] Add group visual indicators in frontend/src/lib/canvas/GroupVisual.svelte
|
||||||
- [ ] T188 [P] [US10] Write grouping tests in frontend/tests/canvas/groups.test.ts
|
- [X] T188 [P] [US10] Write grouping tests in frontend/tests/canvas/groups.test.ts
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- Grouping functional
|
- Grouping functional
|
||||||
|
|||||||
Reference in New Issue
Block a user