develop #21
@@ -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'
|
||||
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
|
||||
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'
|
||||
|
||||
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,
|
||||
onClick,
|
||||
}: {
|
||||
route: GameRoute
|
||||
group: RouteWithChildren
|
||||
gameId: number
|
||||
onClick: (r: GameRoute) => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: route.id })
|
||||
useSortable({ id: group.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -59,45 +85,77 @@ function SortableRouteRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
<tbody
|
||||
ref={setNodeRef}
|
||||
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`}
|
||||
onClick={() => onClick(route)}
|
||||
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} divide-y divide-gray-200 dark:divide-gray-700`}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm w-12">
|
||||
<button
|
||||
{...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"
|
||||
<tr
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
|
||||
onClick={() => onClick(group)}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm w-12">
|
||||
<button
|
||||
{...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">
|
||||
<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">{route.order}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{route.name}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
|
||||
{route.pinwheelZone != null ? route.pinwheelZone : '\u2014'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
<Link
|
||||
to={`/admin/games/${gameId}/routes/${route.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Encounters
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
<td className="px-4 py-3 text-sm w-12" />
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap w-16 text-gray-400 dark:text-gray-500">
|
||||
{child.order}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap pl-8 text-gray-600 dark:text-gray-400">
|
||||
<span className="text-gray-300 dark:text-gray-600 mr-1.5">{'\u2514'}</span>
|
||||
{child.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
|
||||
{child.pinwheelZone != null ? child.pinwheelZone : '\u2014'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
<Link
|
||||
to={`/admin/games/${gameId}/routes/${child.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Encounters
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,24 +272,29 @@ export function AdminGameDetail() {
|
||||
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
|
||||
|
||||
const routes = game.routes ?? []
|
||||
const routeGroups = organizeRoutes(routes)
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = routes.findIndex((r) => r.id === active.id)
|
||||
const newIndex = routes.findIndex((r) => r.id === over.id)
|
||||
const oldIndex = routeGroups.findIndex((g) => g.id === active.id)
|
||||
const newIndex = routeGroups.findIndex((g) => g.id === over.id)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
// Build new order assignments based on rearranged positions
|
||||
const reordered = [...routes]
|
||||
const reordered = [...routeGroups]
|
||||
const [moved] = reordered.splice(oldIndex, 1)
|
||||
reordered.splice(newIndex, 0, moved)
|
||||
|
||||
const newOrders = reordered.map((r, i) => ({
|
||||
id: r.id,
|
||||
order: i + 1,
|
||||
}))
|
||||
// Flatten groups back to individual routes with sequential order numbers
|
||||
let order = 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)
|
||||
}
|
||||
|
||||
@@ -364,19 +427,17 @@ export function AdminGameDetail() {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={routes.map((r) => r.id)}
|
||||
items={routeGroups.map((g) => g.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{routes.map((route) => (
|
||||
<SortableRouteRow
|
||||
key={route.id}
|
||||
route={route}
|
||||
gameId={id}
|
||||
onClick={(r) => setEditing(r)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
{routeGroups.map((group) => (
|
||||
<SortableRouteGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
gameId={id}
|
||||
onClick={(r) => setEditing(r)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user