feat: v0.18.0 part 3 — market row to relocate a grounded plane
CI / build + unit + e2e (pull_request) Failing after 6m17s
CI / build + unit + e2e (pull_request) Failing after 6m17s
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { abandonPenalty } from '@/lib/game/penalty';
|
||||
@@ -122,6 +123,32 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
||||
|
||||
// ISS-010 (v0.18.0): Relocate row selections — same "effective selection" pattern as the rows above:
|
||||
// the user's pick while it stays valid, else a sensible default (first grounded plane / first airport).
|
||||
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
|
||||
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
|
||||
|
||||
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
|
||||
// Relocate row, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
|
||||
// on failure airportOptions stays null and the Relocate button simply remains disabled.
|
||||
const [airportOptions, setAirportOptions] = useState<Array<{ name: string; icao: string }> | null>(null);
|
||||
const airportsPromiseRef = useRef<Promise<Array<{ name: string; icao: string }>> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!airportsPromiseRef.current) {
|
||||
airportsPromiseRef.current = fetch('/api/airports', { cache: 'no-store' })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`);
|
||||
return res.json() as Promise<GeoJsonFeatureCollection>;
|
||||
})
|
||||
.then((fc) =>
|
||||
fc.features
|
||||
.map((f) => ({ name: f.properties.name, icao: f.properties.icao }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'en')),
|
||||
);
|
||||
}
|
||||
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0
|
||||
// convention) — pushed into the shared state immediately; a 4xx {error} body becomes the
|
||||
// inline pane error and the stale UI stays until the next poll. Buttons are disabled while
|
||||
@@ -182,6 +209,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
|
||||
refuelable[0] ??
|
||||
null;
|
||||
// ISS-010 (v0.18.0): Relocate — any GROUNDED plane can be repositioned to any airport via the
|
||||
// existing fly endpoint (pure repositioning, no package needed since v0.12.0).
|
||||
const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null;
|
||||
const relocateDest =
|
||||
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
||||
|
||||
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
|
||||
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
|
||||
@@ -422,6 +454,48 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
Refuel to full
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure
|
||||
repositioning, this row just exposes it for planes with nothing on board. */}
|
||||
<div className="market-row market-row-relocate">
|
||||
<select
|
||||
data-testid="relocate-plane"
|
||||
aria-label="Plane to relocate"
|
||||
value={relocatePlane?.id ?? ''}
|
||||
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
|
||||
>
|
||||
{grounded.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{planeName(p)} — {p.locationIcao}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
data-testid="relocate-dest"
|
||||
aria-label="Relocation destination airport"
|
||||
value={relocateDest?.icao ?? ''}
|
||||
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
||||
>
|
||||
{airportOptions?.map((a) => (
|
||||
<option key={a.icao} value={a.icao}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="relocate-go"
|
||||
className="pane-btn"
|
||||
disabled={busy || relocatePlane === null || relocateDest === null}
|
||||
onClick={() => {
|
||||
if (relocatePlane && relocateDest) {
|
||||
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Relocate
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
||||
|
||||
Reference in New Issue
Block a user