Add bulk import for evolutions, routes, and bosses

Add three new bulk import endpoints that accept the same JSON format as
their corresponding export endpoints, enabling round-trip compatibility:

- POST /evolutions/bulk-import (upsert by from/to pokemon pair)
- POST /games/{id}/routes/bulk-import (reuses seed loader for hierarchy)
- POST /games/{id}/bosses/bulk-import (reuses seed loader with team data)

Generalize BulkImportModal to support all entity types with configurable
title, example, and result labels. Wire up Bulk Import buttons on
AdminEvolutions, and AdminGameDetail routes/bosses tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 20:14:19 +01:00
parent 8e1c8b554f
commit 8f6d72a9c4
12 changed files with 373 additions and 15 deletions

View File

@@ -0,0 +1,27 @@
---
# nuzlocke-tracker-5o1v
title: Improve encounter method input in route encounter form
status: todo
type: feature
created_at: 2026-02-08T19:06:10Z
updated_at: 2026-02-08T19:06:10Z
parent: nuzlocke-tracker-iu5b
---
Replace the free-text encounter method input in the route encounter form (RouteEncounterFormModal) with a smarter selector that leverages the known encounter methods already defined in the codebase.
## Current behavior
- The encounter method field in RouteEncounterFormModal is a plain `<input type="text">` with a placeholder "e.g. Walking, Surfing, Fishing"
- Easy to introduce typos or inconsistent naming (e.g. "walking" vs "walk" vs "Grass")
- The app already has a well-defined set of encounter methods in `EncounterMethodBadge.tsx` with METHOD_CONFIG and METHOD_ORDER (starter, gift, fossil, trade, walk, headbutt, surf, rock-smash, old-rod, good-rod, super-rod)
- The backend stores this as a `String(30)` column, so it's not strictly enum-constrained
## Desired behavior
- Replace the free-text input with a dropdown/select that lists the known encounter methods from METHOD_ORDER, using the human-readable labels from getMethodLabel()
- Include an "Other" option that reveals a text input for custom methods not in the predefined list
- When editing an existing encounter, pre-select the correct method
- Consider showing the colored badge preview next to each option for visual consistency with how methods appear elsewhere in the app
## Files
- `frontend/src/components/admin/RouteEncounterFormModal.tsx` — replace the text input with new selector
- `frontend/src/components/EncounterMethodBadge.tsx` — export METHOD_CONFIG or add a helper to get the list of known methods

View File

@@ -1,10 +1,11 @@
---
# nuzlocke-tracker-ok1t
title: Bulk import for evolutions, routes, and bosses
status: todo
status: completed
type: feature
priority: normal
created_at: 2026-02-08T12:33:39Z
updated_at: 2026-02-08T12:33:39Z
updated_at: 2026-02-08T19:13:27Z
parent: nuzlocke-tracker-iu5b
---

View File

@@ -11,6 +11,8 @@ from app.models.boss_pokemon import BossPokemon
from app.models.boss_result import BossResult
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.schemas.boss import (
BossBattleCreate,
BossBattleResponse,
@@ -20,6 +22,8 @@ from app.schemas.boss import (
BossResultCreate,
BossResultResponse,
)
from app.schemas.pokemon import BulkBossItem, BulkImportResult
from app.seeds.loader import upsert_bosses
router = APIRouter()
@@ -164,6 +168,34 @@ async def delete_boss(
return Response(status_code=204)
@router.post("/games/{game_id}/bosses/bulk-import", response_model=BulkImportResult)
async def bulk_import_bosses(
game_id: int,
items: list[BulkBossItem],
session: AsyncSession = Depends(get_session),
):
vg_id = await _get_version_group_id(session, game_id)
# Build pokeapi_id -> id mapping
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
dex_to_id = {row.pokeapi_id: row.id for row in result}
# Build route name -> id mapping for after_route_name resolution
result = await session.execute(
select(Route.name, Route.id).where(Route.version_group_id == vg_id)
)
route_name_to_id = {row.name: row.id for row in result}
bosses_data = [item.model_dump() for item in items]
try:
count = await upsert_bosses(session, vg_id, bosses_data, dex_to_id, route_name_to_id)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to import bosses: {e}")
await session.commit()
return BulkImportResult(created=count, updated=0, errors=[])
@router.put(
"/games/{game_id}/bosses/{boss_id}/pokemon",
response_model=BossBattleResponse,

View File

@@ -7,6 +7,8 @@ from app.core.database import get_session
from app.models.evolution import Evolution
from app.models.pokemon import Pokemon
from app.schemas.pokemon import (
BulkEvolutionItem,
BulkImportResult,
EvolutionAdminResponse,
EvolutionCreate,
EvolutionUpdate,
@@ -144,3 +146,65 @@ async def delete_evolution(
await session.delete(evolution)
await session.commit()
@router.post("/evolutions/bulk-import", response_model=BulkImportResult)
async def bulk_import_evolutions(
items: list[BulkEvolutionItem],
session: AsyncSession = Depends(get_session),
):
# Build pokeapi_id -> id mapping
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
dex_to_id = {row.pokeapi_id: row.id for row in result}
created = 0
updated = 0
errors: list[str] = []
for item in items:
from_id = dex_to_id.get(item.from_pokeapi_id)
to_id = dex_to_id.get(item.to_pokeapi_id)
if from_id is None:
errors.append(f"Pokemon with pokeapi_id {item.from_pokeapi_id} not found")
continue
if to_id is None:
errors.append(f"Pokemon with pokeapi_id {item.to_pokeapi_id} not found")
continue
try:
# Check if evolution already exists
existing = await session.execute(
select(Evolution).where(
Evolution.from_pokemon_id == from_id,
Evolution.to_pokemon_id == to_id,
)
)
evolution = existing.scalar_one_or_none()
if evolution is not None:
evolution.trigger = item.trigger
evolution.min_level = item.min_level
evolution.item = item.item
evolution.held_item = item.held_item
evolution.condition = item.condition
evolution.region = item.region
updated += 1
else:
evolution = Evolution(
from_pokemon_id=from_id,
to_pokemon_id=to_id,
trigger=item.trigger,
min_level=item.min_level,
item=item.item,
held_item=item.held_item,
condition=item.condition,
region=item.region,
)
session.add(evolution)
created += 1
except Exception as e:
errors.append(f"Evolution {item.from_pokeapi_id} -> {item.to_pokeapi_id}: {e}")
await session.commit()
return BulkImportResult(created=created, updated=updated, errors=errors)

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import selectinload
from app.core.database import get_session
from app.models.boss_battle import BossBattle
from app.models.game import Game
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.route_encounter import RouteEncounter
from app.schemas.game import (
@@ -19,6 +20,8 @@ from app.schemas.game import (
RouteUpdate,
RouteWithChildrenResponse,
)
from app.schemas.pokemon import BulkImportResult, BulkRouteItem
from app.seeds.loader import upsert_route_encounters, upsert_routes
router = APIRouter()
@@ -332,3 +335,68 @@ async def delete_route(
await session.delete(route)
await session.commit()
@router.post("/{game_id}/routes/bulk-import", response_model=BulkImportResult)
async def bulk_import_routes(
game_id: int,
items: list[BulkRouteItem],
session: AsyncSession = Depends(get_session),
):
vg_id = await _get_version_group_id(session, game_id)
# Build pokeapi_id -> id mapping for encounter resolution
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
dex_to_id = {row.pokeapi_id: row.id for row in result}
errors: list[str] = []
# Upsert routes using the seed loader (handles parent/child hierarchy)
routes_data = [item.model_dump() for item in items]
try:
route_name_to_id = await upsert_routes(session, vg_id, routes_data)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to import routes: {e}")
# Upsert encounters for each route
encounter_count = 0
for item in items:
route_id = route_name_to_id.get(item.name)
if route_id is None:
errors.append(f"Route '{item.name}' not found after upsert")
continue
if item.encounters:
try:
count = await upsert_route_encounters(
session, route_id, [e.model_dump() for e in item.encounters],
dex_to_id, game_id,
)
encounter_count += count
except Exception as e:
errors.append(f"Encounters for '{item.name}': {e}")
for child in item.children:
child_id = route_name_to_id.get(child.name)
if child_id is None:
errors.append(f"Child route '{child.name}' not found after upsert")
continue
if child.encounters:
try:
count = await upsert_route_encounters(
session, child_id, [e.model_dump() for e in child.encounters],
dex_to_id, game_id,
)
encounter_count += count
except Exception as e:
errors.append(f"Encounters for '{child.name}': {e}")
await session.commit()
route_count = len(route_name_to_id)
return BulkImportResult(
created=route_count,
updated=encounter_count,
errors=errors,
)

View File

@@ -158,3 +158,60 @@ class EvolutionUpdate(CamelModel):
held_item: str | None = None
condition: str | None = None
region: str | None = None
# --- Bulk import schemas (match export format, snake_case) ---
class BulkEvolutionItem(BaseModel):
from_pokeapi_id: int
to_pokeapi_id: int
trigger: str
min_level: int | None = None
item: str | None = None
held_item: str | None = None
condition: str | None = None
region: str | None = None
class BulkRouteEncounterItem(BaseModel):
pokeapi_id: int
method: str
encounter_rate: int
min_level: int
max_level: int
class BulkRouteChildItem(BaseModel):
name: str
order: int
pinwheel_zone: int | None = None
encounters: list[BulkRouteEncounterItem] = []
class BulkRouteItem(BaseModel):
name: str
order: int
encounters: list[BulkRouteEncounterItem] = []
children: list[BulkRouteChildItem] = []
class BulkBossPokemonItem(BaseModel):
pokeapi_id: int
level: int
order: int
class BulkBossItem(BaseModel):
name: str
boss_type: str
specialty_type: str | None = None
badge_name: str | None = None
badge_image_url: str | None = None
level_cap: int
order: int
after_route_name: str | None = None
location: str
section: str | None = None
sprite_url: str | None = None
pokemon: list[BulkBossPokemonItem] = []

View File

@@ -70,6 +70,15 @@ export const deletePokemon = (id: number) =>
export const bulkImportPokemon = (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
api.post<BulkImportResult>('/pokemon/bulk-import', items)
export const bulkImportEvolutions = (items: unknown[]) =>
api.post<BulkImportResult>('/evolutions/bulk-import', items)
export const bulkImportRoutes = (gameId: number, items: unknown[]) =>
api.post<BulkImportResult>(`/games/${gameId}/routes/bulk-import`, items)
export const bulkImportBosses = (gameId: number, items: unknown[]) =>
api.post<BulkImportResult>(`/games/${gameId}/bosses/bulk-import`, items)
// Evolutions
export const listEvolutions = (search?: string, limit = 50, offset = 0) => {
const params = new URLSearchParams()

View File

@@ -2,16 +2,17 @@ import { type FormEvent, useState } from 'react'
import type { BulkImportResult } from '../../types'
interface BulkImportModalProps {
onSubmit: (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => Promise<BulkImportResult>
title: string
example?: string
onSubmit: (items: unknown[]) => Promise<BulkImportResult>
onClose: () => void
/** Label for the "created" count in the result summary */
createdLabel?: string
/** Label for the "updated" count in the result summary */
updatedLabel?: string
}
const EXAMPLE = `[
{ "pokeapiId": 1, "nationalDex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] },
{ "pokeapiId": 4, "nationalDex": 4, "name": "Charmander", "types": ["Fire"] }
]`
export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
export function BulkImportModal({ title, example, onSubmit, onClose, createdLabel = 'Created', updatedLabel = 'Updated' }: BulkImportModalProps) {
const [json, setJson] = useState('')
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<BulkImportResult | null>(null)
@@ -27,13 +28,13 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
items = JSON.parse(json)
if (!Array.isArray(items)) throw new Error('Must be an array')
} catch {
setError('Invalid JSON. Must be an array of pokemon objects.')
setError('Invalid JSON. Must be an array of objects.')
return
}
setIsSubmitting(true)
try {
const res = await onSubmit(items as Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[] }>)
const res = await onSubmit(items)
setResult(res)
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed')
@@ -47,7 +48,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">Bulk Import Pokemon</h2>
<h2 className="text-lg font-semibold">{title}</h2>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4">
@@ -59,7 +60,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
rows={12}
value={json}
onChange={(e) => setJson(e.target.value)}
placeholder={EXAMPLE}
placeholder={example}
className="w-full px-3 py-2 border rounded-md font-mono text-sm dark:bg-gray-700 dark:border-gray-600"
/>
</div>
@@ -72,7 +73,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
{result && (
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
<p>Created: {result.created}, Updated: {result.updated}</p>
<p>{createdLabel}: {result.created}, {updatedLabel}: {result.updated}</p>
{result.errors.length > 0 && (
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
{result.errors.map((err, i) => (

View File

@@ -174,6 +174,43 @@ export function useBulkImportPokemon() {
})
}
export function useBulkImportEvolutions() {
const qc = useQueryClient()
return useMutation({
mutationFn: (items: unknown[]) => adminApi.bulkImportEvolutions(items),
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ['evolutions'] })
toast.success(`Import complete: ${result.created} created, ${result.updated} updated`)
},
onError: (err) => toast.error(`Import failed: ${err.message}`),
})
}
export function useBulkImportRoutes(gameId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (items: unknown[]) => adminApi.bulkImportRoutes(gameId, items),
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ['games', gameId] })
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
toast.success(`Import complete: ${result.created} routes, ${result.updated} encounters`)
},
onError: (err) => toast.error(`Import failed: ${err.message}`),
})
}
export function useBulkImportBosses(gameId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (items: unknown[]) => adminApi.bulkImportBosses(gameId, items),
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
toast.success(`Import complete: ${result.created} bosses imported`)
},
onError: (err) => toast.error(`Import failed: ${err.message}`),
})
}
// --- Evolution Queries & Mutations ---
export function useEvolutionList(search?: string, limit = 50, offset = 0) {

View File

@@ -1,11 +1,13 @@
import { useState } from 'react'
import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { BulkImportModal } from '../../components/admin/BulkImportModal'
import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal'
import {
useEvolutionList,
useCreateEvolution,
useUpdateEvolution,
useDeleteEvolution,
useBulkImportEvolutions,
} from '../../hooks/useAdmin'
import { exportEvolutions } from '../../api/admin'
import { downloadJson } from '../../utils/download'
@@ -25,8 +27,10 @@ export function AdminEvolutions() {
const createEvolution = useCreateEvolution()
const updateEvolution = useUpdateEvolution()
const deleteEvolution = useDeleteEvolution()
const bulkImport = useBulkImportEvolutions()
const [showCreate, setShowCreate] = useState(false)
const [showBulkImport, setShowBulkImport] = useState(false)
const [editing, setEditing] = useState<EvolutionAdmin | null>(null)
const columns: Column<EvolutionAdmin>[] = [
@@ -71,6 +75,12 @@ export function AdminEvolutions() {
>
Export
</button>
<button
onClick={() => setShowBulkImport(true)}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Bulk Import
</button>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
@@ -146,6 +156,15 @@ export function AdminEvolutions() {
</div>
)}
{showBulkImport && (
<BulkImportModal
title="Bulk Import Evolutions"
example={`[\n { "from_pokeapi_id": 1, "to_pokeapi_id": 2, "trigger": "level-up", "min_level": 16 }\n]`}
onSubmit={(items) => bulkImport.mutateAsync(items)}
onClose={() => setShowBulkImport(false)}
/>
)}
{showCreate && (
<EvolutionFormModal
onSubmit={(data) =>

View File

@@ -16,6 +16,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { BulkImportModal } from '../../components/admin/BulkImportModal'
import { RouteFormModal } from '../../components/admin/RouteFormModal'
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
@@ -27,11 +28,13 @@ import {
useUpdateRoute,
useDeleteRoute,
useReorderRoutes,
useBulkImportRoutes,
useReorderBosses,
useCreateBossBattle,
useUpdateBossBattle,
useDeleteBossBattle,
useSetBossTeam,
useBulkImportBosses,
} from '../../hooks/useAdmin'
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
import { downloadJson } from '../../utils/download'
@@ -149,16 +152,20 @@ export function AdminGameDetail() {
const updateRoute = useUpdateRoute(id)
const deleteRoute = useDeleteRoute(id)
const reorderRoutes = useReorderRoutes(id)
const bulkImportRoutes = useBulkImportRoutes(id)
const { data: bosses } = useGameBosses(id)
const createBoss = useCreateBossBattle(id)
const updateBoss = useUpdateBossBattle(id)
const deleteBoss = useDeleteBossBattle(id)
const reorderBosses = useReorderBosses(id)
const bulkImportBosses = useBulkImportBosses(id)
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
const [showCreate, setShowCreate] = useState(false)
const [showBulkImportRoutes, setShowBulkImportRoutes] = useState(false)
const [editing, setEditing] = useState<GameRoute | null>(null)
const [showCreateBoss, setShowCreateBoss] = useState(false)
const [showBulkImportBosses, setShowBulkImportBosses] = useState(false)
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
@@ -265,6 +272,12 @@ export function AdminGameDetail() {
>
Export
</button>
<button
onClick={() => setShowBulkImportRoutes(true)}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Bulk Import
</button>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
@@ -273,6 +286,17 @@ export function AdminGameDetail() {
</button>
</div>
{showBulkImportRoutes && (
<BulkImportModal
title="Bulk Import Routes"
example={`[\n { "name": "Route 1", "order": 1, "encounters": [...], "children": [...] }\n]`}
createdLabel="Routes"
updatedLabel="Encounters"
onSubmit={(items) => bulkImportRoutes.mutateAsync(items)}
onClose={() => setShowBulkImportRoutes(false)}
/>
)}
{routes.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
No routes yet. Add one to get started.
@@ -365,6 +389,12 @@ export function AdminGameDetail() {
>
Export
</button>
<button
onClick={() => setShowBulkImportBosses(true)}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Bulk Import
</button>
<button
onClick={() => setShowCreateBoss(true)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
@@ -373,6 +403,17 @@ export function AdminGameDetail() {
</button>
</div>
{showBulkImportBosses && (
<BulkImportModal
title="Bulk Import Boss Battles"
example={`[\n { "name": "Brock", "boss_type": "gym_leader", "level_cap": 15, "order": 1, "location": "Pewter City", "pokemon": [...] }\n]`}
createdLabel="Bosses"
updatedLabel="Updated"
onSubmit={(items) => bulkImportBosses.mutateAsync(items)}
onClose={() => setShowBulkImportBosses(false)}
/>
)}
{!bosses || bosses.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
No boss battles yet. Add one to get started.

View File

@@ -158,7 +158,9 @@ export function AdminPokemon() {
{showBulkImport && (
<BulkImportModal
onSubmit={(items) => bulkImport.mutateAsync(items)}
title="Bulk Import Pokemon"
example={`[\n { "pokeapi_id": 1, "national_dex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }\n]`}
onSubmit={(items) => bulkImport.mutateAsync(items as Parameters<typeof bulkImport.mutateAsync>[0])}
onClose={() => setShowBulkImport(false)}
/>
)}