fix: pin route ordering and star icon overlap in grid view
Move GET /pinned and PATCH /pinned/reorder before GET /{tree_id} to
prevent FastAPI from matching "pinned" as a UUID path parameter (422).
Relocate star button from absolute positioning into the header row to
avoid overlapping privacy icons and category badges.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -229,6 +229,94 @@ async def list_categories(
|
|||||||
return sorted(categories)
|
return sorted(categories)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pinned Flows Endpoints (must be before /{tree_id} to avoid route shadowing) ---
|
||||||
|
|
||||||
|
MAX_PINNED_FLOWS = 15
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pinned", response_model=PinnedFlowsListResponse)
|
||||||
|
async def list_pinned_flows(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
|
):
|
||||||
|
"""List user's pinned flows, ordered by display_order."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserPinnedTree, Tree)
|
||||||
|
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||||
|
.options(selectinload(Tree.category_rel))
|
||||||
|
.where(
|
||||||
|
UserPinnedTree.user_id == current_user.id,
|
||||||
|
Tree.is_active == True,
|
||||||
|
Tree.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for pin, tree in rows:
|
||||||
|
items.append(PinnedFlowResponse(
|
||||||
|
id=pin.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
tree_name=tree.name,
|
||||||
|
tree_type=tree.tree_type,
|
||||||
|
category_emoji=None,
|
||||||
|
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||||
|
pinned_at=pin.pinned_at,
|
||||||
|
display_order=pin.display_order,
|
||||||
|
))
|
||||||
|
|
||||||
|
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/pinned/reorder", response_model=PinnedFlowsListResponse)
|
||||||
|
async def reorder_pinned_flows(
|
||||||
|
reorder_data: PinnedFlowReorderRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
|
):
|
||||||
|
"""Update display_order for all pinned flows."""
|
||||||
|
for item in reorder_data.order:
|
||||||
|
await db.execute(
|
||||||
|
update(UserPinnedTree)
|
||||||
|
.where(
|
||||||
|
UserPinnedTree.user_id == current_user.id,
|
||||||
|
UserPinnedTree.tree_id == item.tree_id
|
||||||
|
)
|
||||||
|
.values(display_order=item.display_order)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Return updated list
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserPinnedTree, Tree)
|
||||||
|
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||||
|
.options(selectinload(Tree.category_rel))
|
||||||
|
.where(
|
||||||
|
UserPinnedTree.user_id == current_user.id,
|
||||||
|
Tree.is_active == True,
|
||||||
|
Tree.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for pin, tree in rows:
|
||||||
|
items.append(PinnedFlowResponse(
|
||||||
|
id=pin.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
tree_name=tree.name,
|
||||||
|
tree_type=tree.tree_type,
|
||||||
|
category_emoji=None,
|
||||||
|
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||||
|
pinned_at=pin.pinned_at,
|
||||||
|
display_order=pin.display_order,
|
||||||
|
))
|
||||||
|
|
||||||
|
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search", response_model=list[TreeListResponse])
|
@router.get("/search", response_model=list[TreeListResponse])
|
||||||
async def search_trees(
|
async def search_trees(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
@@ -1034,46 +1122,6 @@ async def check_tree_can_publish(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Pinned Flows Endpoints ---
|
|
||||||
|
|
||||||
MAX_PINNED_FLOWS = 15
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pinned", response_model=PinnedFlowsListResponse)
|
|
||||||
async def list_pinned_flows(
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
|
||||||
):
|
|
||||||
"""List user's pinned flows, ordered by display_order."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(UserPinnedTree, Tree)
|
|
||||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
|
||||||
.options(selectinload(Tree.category_rel))
|
|
||||||
.where(
|
|
||||||
UserPinnedTree.user_id == current_user.id,
|
|
||||||
Tree.is_active == True,
|
|
||||||
Tree.deleted_at.is_(None)
|
|
||||||
)
|
|
||||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
|
||||||
)
|
|
||||||
rows = result.all()
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for pin, tree in rows:
|
|
||||||
items.append(PinnedFlowResponse(
|
|
||||||
id=pin.id,
|
|
||||||
tree_id=tree.id,
|
|
||||||
tree_name=tree.name,
|
|
||||||
tree_type=tree.tree_type,
|
|
||||||
category_emoji=None,
|
|
||||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
|
||||||
pinned_at=pin.pinned_at,
|
|
||||||
display_order=pin.display_order,
|
|
||||||
))
|
|
||||||
|
|
||||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{tree_id}/pin", response_model=PinnedFlowResponse)
|
@router.post("/{tree_id}/pin", response_model=PinnedFlowResponse)
|
||||||
async def pin_flow(
|
async def pin_flow(
|
||||||
tree_id: UUID,
|
tree_id: UUID,
|
||||||
@@ -1166,51 +1214,3 @@ async def unpin_flow(
|
|||||||
await db.delete(pin)
|
await db.delete(pin)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/pinned/reorder", response_model=PinnedFlowsListResponse)
|
|
||||||
async def reorder_pinned_flows(
|
|
||||||
reorder_data: PinnedFlowReorderRequest,
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
|
||||||
):
|
|
||||||
"""Update display_order for all pinned flows."""
|
|
||||||
for item in reorder_data.order:
|
|
||||||
await db.execute(
|
|
||||||
update(UserPinnedTree)
|
|
||||||
.where(
|
|
||||||
UserPinnedTree.user_id == current_user.id,
|
|
||||||
UserPinnedTree.tree_id == item.tree_id
|
|
||||||
)
|
|
||||||
.values(display_order=item.display_order)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# Return updated list
|
|
||||||
result = await db.execute(
|
|
||||||
select(UserPinnedTree, Tree)
|
|
||||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
|
||||||
.options(selectinload(Tree.category_rel))
|
|
||||||
.where(
|
|
||||||
UserPinnedTree.user_id == current_user.id,
|
|
||||||
Tree.is_active == True,
|
|
||||||
Tree.deleted_at.is_(None)
|
|
||||||
)
|
|
||||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
|
||||||
)
|
|
||||||
rows = result.all()
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for pin, tree in rows:
|
|
||||||
items.append(PinnedFlowResponse(
|
|
||||||
id=pin.id,
|
|
||||||
tree_id=tree.id,
|
|
||||||
tree_name=tree.name,
|
|
||||||
tree_type=tree.tree_type,
|
|
||||||
category_emoji=None,
|
|
||||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
|
||||||
pinned_at=pin.pinned_at,
|
|
||||||
display_order=pin.display_order,
|
|
||||||
))
|
|
||||||
|
|
||||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
|
||||||
|
|||||||
@@ -37,26 +37,6 @@ export function TreeGridView({
|
|||||||
key={tree.id}
|
key={tree.id}
|
||||||
className="relative bg-card border border-border rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
className="relative bg-card border border-border rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||||
>
|
>
|
||||||
{onTogglePin && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
onTogglePin(tree.id)
|
|
||||||
}}
|
|
||||||
disabled={pinLoadingTreeIds?.has(tree.id)}
|
|
||||||
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
|
|
||||||
className={cn(
|
|
||||||
'absolute top-3 right-3 z-10 rounded-md p-1 transition-colors',
|
|
||||||
pinnedTreeIds?.has(tree.id)
|
|
||||||
? 'text-amber-400 hover:text-amber-300'
|
|
||||||
: 'text-muted-foreground/40 hover:text-amber-400',
|
|
||||||
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold text-foreground">{tree.name}</h3>
|
<h3 className="font-semibold text-foreground">{tree.name}</h3>
|
||||||
@@ -74,6 +54,26 @@ export function TreeGridView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{onTogglePin && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
onTogglePin(tree.id)
|
||||||
|
}}
|
||||||
|
disabled={pinLoadingTreeIds?.has(tree.id)}
|
||||||
|
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1 transition-colors',
|
||||||
|
pinnedTreeIds?.has(tree.id)
|
||||||
|
? 'text-amber-400 hover:text-amber-300'
|
||||||
|
: 'text-muted-foreground/40 hover:text-amber-400',
|
||||||
|
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Star size={14} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{tree.is_public ? (
|
{tree.is_public ? (
|
||||||
<span title="Public tree">
|
<span title="Public tree">
|
||||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
|||||||
Reference in New Issue
Block a user