001-reference-board-viewer #1
@@ -1,10 +1,9 @@
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
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_image import BoardImage
|
||||
from app.database.models.group import Group
|
||||
|
||||
|
||||
class BoardRepository:
|
||||
@@ -195,3 +196,213 @@ class BoardRepository:
|
||||
count = self.db.execute(stmt).scalar_one()
|
||||
|
||||
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):
|
||||
return ViewportState(**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.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.errors import WebRefException
|
||||
from app.core.logging import setup_logging
|
||||
@@ -84,6 +84,7 @@ async def root():
|
||||
# API routers
|
||||
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(groups.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."""
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Integration tests for bulk image operations."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
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.image import Image
|
||||
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
|
||||
@@ -26,7 +27,7 @@ async def test_bulk_update_position_delta(client: AsyncClient, test_user: User,
|
||||
# Create images
|
||||
images = []
|
||||
board_images = []
|
||||
|
||||
|
||||
for i in range(3):
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
|
||||
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."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
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.image import Image
|
||||
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
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Integration tests for image position update endpoint."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
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.image import Image
|
||||
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
|
||||
@@ -441,11 +442,11 @@ async def test_update_preserves_other_fields(client: AsyncClient, test_user: Use
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
|
||||
# Position should be updated
|
||||
assert data["position"]["x"] == 200
|
||||
assert data["position"]["y"] == 200
|
||||
|
||||
|
||||
# Other fields should be preserved
|
||||
assert data["transformations"]["scale"] == 1.5
|
||||
assert data["transformations"]["rotation"] == 45
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Integration tests for image upload endpoints."""
|
||||
|
||||
import io
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Integration tests for Z-order persistence."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
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.image import Image
|
||||
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
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.auth.jwt import create_access_token, decode_access_token
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Unit tests for password hashing and validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.security import hash_password, validate_password_strength, verify_password
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Pytest configuration and fixtures for all tests."""
|
||||
|
||||
import os
|
||||
from typing import Generator
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import io
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.images.processing import generate_thumbnails
|
||||
|
||||
@@ -44,7 +44,7 @@ def test_transformation_scale_bounds():
|
||||
"""Test scale bounds validation."""
|
||||
# Valid scales
|
||||
valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0]
|
||||
|
||||
|
||||
for scale in valid_scales:
|
||||
data = BoardImageUpdate(transformations={"scale": scale})
|
||||
assert data.transformations["scale"] == scale
|
||||
@@ -54,7 +54,7 @@ def test_transformation_rotation_bounds():
|
||||
"""Test rotation bounds (any value allowed, normalized client-side)."""
|
||||
# Various rotation values
|
||||
rotations = [0, 45, 90, 180, 270, 360, 450, -90]
|
||||
|
||||
|
||||
for rotation in rotations:
|
||||
data = BoardImageUpdate(transformations={"rotation": rotation})
|
||||
assert data.transformations["rotation"] == rotation
|
||||
@@ -64,7 +64,7 @@ def test_transformation_opacity_bounds():
|
||||
"""Test opacity bounds."""
|
||||
# Valid opacity values
|
||||
valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0]
|
||||
|
||||
|
||||
for opacity in valid_opacities:
|
||||
data = BoardImageUpdate(transformations={"opacity": opacity})
|
||||
assert data.transformations["opacity"] == opacity
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for file validation."""
|
||||
|
||||
import io
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
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
|
||||
|
||||
**Independent Test Criteria:**
|
||||
- [ ] Users can create groups from selection
|
||||
- [ ] Groups have text annotations
|
||||
- [ ] Groups have colored labels
|
||||
- [ ] Groups move as single unit
|
||||
- [ ] Groups can be ungrouped
|
||||
- [X] Users can create groups from selection
|
||||
- [X] Groups have text annotations
|
||||
- [X] Groups have colored labels
|
||||
- [X] Groups move as single unit
|
||||
- [X] Groups can be ungrouped
|
||||
|
||||
**Backend Tasks:**
|
||||
|
||||
- [ ] 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)
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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] T172 [P] [US10] Create Group model in backend/app/database/models/group.py from data-model.md
|
||||
- [X] T173 [P] [US10] Create group schemas in backend/app/boards/schemas.py (GroupCreate, GroupResponse)
|
||||
- [X] T174 [US10] Create group repository in backend/app/boards/repository.py (group operations)
|
||||
- [X] T175 [US10] Implement create group endpoint POST /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
|
||||
- [X] T177 [US10] Implement update group endpoint PATCH /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
|
||||
- [X] T179 [P] [US10] Write group endpoint tests in backend/tests/api/test_groups.py
|
||||
|
||||
**Frontend Tasks:**
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] T188 [P] [US10] Write grouping tests in frontend/tests/canvas/groups.test.ts
|
||||
- [X] T180 [P] [US10] Create groups API client in frontend/src/lib/api/groups.ts
|
||||
- [X] T181 [P] [US10] Create groups store in frontend/src/lib/stores/groups.ts
|
||||
- [X] T182 [US10] Implement create group from selection in frontend/src/lib/canvas/operations/group.ts
|
||||
- [X] T183 [US10] Implement group move as unit in frontend/src/lib/canvas/operations/group-move.ts
|
||||
- [X] T184 [US10] Implement ungroup operation in frontend/src/lib/canvas/operations/ungroup.ts
|
||||
- [X] T185 [P] [US10] Create group annotation UI in frontend/src/lib/components/canvas/GroupAnnotation.svelte
|
||||
- [X] T186 [P] [US10] Create color picker for groups in frontend/src/lib/components/canvas/ColorPicker.svelte
|
||||
- [X] T187 [P] [US10] Add group visual indicators in frontend/src/lib/canvas/GroupVisual.svelte
|
||||
- [X] T188 [P] [US10] Write grouping tests in frontend/tests/canvas/groups.test.ts
|
||||
|
||||
**Deliverables:**
|
||||
- Grouping functional
|
||||
|
||||
Reference in New Issue
Block a user