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:
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user