Group parent/child routes in admin route table

Visually indent child routes under their parent with tree connectors,
and make dragging a parent move all its children as a unit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-13 14:37:35 +01:00
parent 867ded8fa2
commit 1b6970a982
2 changed files with 139 additions and 59 deletions

View File

@@ -0,0 +1,19 @@
---
# nuzlocke-tracker-hit0
title: Group parent/child routes in admin route table
status: completed
type: feature
priority: normal
created_at: 2026-02-13T13:33:36Z
updated_at: 2026-02-13T13:34:42Z
---
Visually indent child routes under their parent in the admin route table. Dragging a parent route moves all its children with it. Children cannot be independently dragged to a new position.
## Checklist
- [x] Add organizeRoutes() function to AdminGameDetail.tsx
- [x] Replace SortableRouteRow with SortableRouteGroup using multiple tbody elements
- [x] Update SortableContext to only track group IDs
- [x] Update handleDragEnd for group-aware reordering
- [x] Handle edge cases (standalone routes, orphan children)
- [x] Verify frontend build passes

View File

@@ -38,20 +38,46 @@ import {
} from '../../hooks/useAdmin' } from '../../hooks/useAdmin'
import { exportGameRoutes, exportGameBosses } from '../../api/admin' import { exportGameRoutes, exportGameBosses } from '../../api/admin'
import { downloadJson } from '../../utils/download' import { downloadJson } from '../../utils/download'
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types' import type { Route as GameRoute, RouteWithChildren, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin' import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
function SortableRouteRow({ /**
route, * Organize flat routes into hierarchical structure.
* Routes with parentRouteId are grouped under their parent.
* Orphan children (parent missing) are treated as top-level.
*/
function organizeRoutes(routes: GameRoute[]): RouteWithChildren[] {
const childrenByParent = new Map<number, GameRoute[]>()
const topLevel: GameRoute[] = []
const parentIds = new Set(routes.map((r) => r.id))
for (const route of routes) {
if (route.parentRouteId === null || !parentIds.has(route.parentRouteId)) {
topLevel.push(route)
} else {
const children = childrenByParent.get(route.parentRouteId) ?? []
children.push(route)
childrenByParent.set(route.parentRouteId, children)
}
}
return topLevel.map((route) => ({
...route,
children: childrenByParent.get(route.id) ?? [],
}))
}
function SortableRouteGroup({
group,
gameId, gameId,
onClick, onClick,
}: { }: {
route: GameRoute group: RouteWithChildren
gameId: number gameId: number
onClick: (r: GameRoute) => void onClick: (r: GameRoute) => void
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: route.id }) useSortable({ id: group.id })
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -59,45 +85,77 @@ function SortableRouteRow({
} }
return ( return (
<tr <tbody
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer`} className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} divide-y divide-gray-200 dark:divide-gray-700`}
onClick={() => onClick(route)}
> >
<td className="px-4 py-3 text-sm w-12"> <tr
<button className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
{...attributes} onClick={() => onClick(group)}
{...listeners} >
onClick={(e) => e.stopPropagation()} <td className="px-4 py-3 text-sm w-12">
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none" <button
title="Drag to reorder" {...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
title="Drag to reorder"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="3" r="1.5" />
<circle cx="11" cy="3" r="1.5" />
<circle cx="5" cy="8" r="1.5" />
<circle cx="11" cy="8" r="1.5" />
<circle cx="5" cy="13" r="1.5" />
<circle cx="11" cy="13" r="1.5" />
</svg>
</button>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{group.order}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{group.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
{group.pinwheelZone != null ? group.pinwheelZone : '\u2014'}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<Link
to={`/admin/games/${gameId}/routes/${group.id}`}
onClick={(e) => e.stopPropagation()}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Encounters
</Link>
</td>
</tr>
{group.children.map((child) => (
<tr
key={child.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
onClick={() => onClick(child)}
> >
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <td className="px-4 py-3 text-sm w-12" />
<circle cx="5" cy="3" r="1.5" /> <td className="px-4 py-3 text-sm whitespace-nowrap w-16 text-gray-400 dark:text-gray-500">
<circle cx="11" cy="3" r="1.5" /> {child.order}
<circle cx="5" cy="8" r="1.5" /> </td>
<circle cx="11" cy="8" r="1.5" /> <td className="px-4 py-3 text-sm whitespace-nowrap pl-8 text-gray-600 dark:text-gray-400">
<circle cx="5" cy="13" r="1.5" /> <span className="text-gray-300 dark:text-gray-600 mr-1.5">{'\u2514'}</span>
<circle cx="11" cy="13" r="1.5" /> {child.name}
</svg> </td>
</button> <td className="px-4 py-3 text-sm whitespace-nowrap text-center">
</td> {child.pinwheelZone != null ? child.pinwheelZone : '\u2014'}
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{route.order}</td> </td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{route.name}</td> <td className="px-4 py-3 text-sm whitespace-nowrap">
<td className="px-4 py-3 text-sm whitespace-nowrap text-center"> <Link
{route.pinwheelZone != null ? route.pinwheelZone : '\u2014'} to={`/admin/games/${gameId}/routes/${child.id}`}
</td> onClick={(e) => e.stopPropagation()}
<td className="px-4 py-3 text-sm whitespace-nowrap"> className="text-blue-600 dark:text-blue-400 hover:underline"
<Link >
to={`/admin/games/${gameId}/routes/${route.id}`} Encounters
onClick={(e) => e.stopPropagation()} </Link>
className="text-blue-600 dark:text-blue-400 hover:underline" </td>
> </tr>
Encounters ))}
</Link> </tbody>
</td>
</tr>
) )
} }
@@ -214,24 +272,29 @@ export function AdminGameDetail() {
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div> if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
const routes = game.routes ?? [] const routes = game.routes ?? []
const routeGroups = organizeRoutes(routes)
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event const { active, over } = event
if (!over || active.id === over.id) return if (!over || active.id === over.id) return
const oldIndex = routes.findIndex((r) => r.id === active.id) const oldIndex = routeGroups.findIndex((g) => g.id === active.id)
const newIndex = routes.findIndex((r) => r.id === over.id) const newIndex = routeGroups.findIndex((g) => g.id === over.id)
if (oldIndex === -1 || newIndex === -1) return if (oldIndex === -1 || newIndex === -1) return
// Build new order assignments based on rearranged positions const reordered = [...routeGroups]
const reordered = [...routes]
const [moved] = reordered.splice(oldIndex, 1) const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved) reordered.splice(newIndex, 0, moved)
const newOrders = reordered.map((r, i) => ({ // Flatten groups back to individual routes with sequential order numbers
id: r.id, let order = 1
order: i + 1, const newOrders: { id: number; order: number }[] = []
})) for (const group of reordered) {
newOrders.push({ id: group.id, order: order++ })
for (const child of group.children) {
newOrders.push({ id: child.id, order: order++ })
}
}
reorderRoutes.mutate(newOrders) reorderRoutes.mutate(newOrders)
} }
@@ -364,19 +427,17 @@ export function AdminGameDetail() {
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext
items={routes.map((r) => r.id)} items={routeGroups.map((g) => g.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700"> {routeGroups.map((group) => (
{routes.map((route) => ( <SortableRouteGroup
<SortableRouteRow key={group.id}
key={route.id} group={group}
route={route} gameId={id}
gameId={id} onClick={(r) => setEditing(r)}
onClick={(r) => setEditing(r)} />
/> ))}
))}
</tbody>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</table> </table>