Plan: Placement Form Case/Child Picker
On this page
Status
COMPLETE — all implementation steps landed prior to this plan’s explicit tracking; this file now serves as audit trail.
| Step | Description | Status |
|---|---|---|
1 |
Root cause investigation |
Done (pre-ADR-030) |
2 |
Fix case list API path + add logging to fetch_page |
Done (pre-ADR-030) — |
3 |
Implement case select dropdown |
Done (pre-ADR-030) — |
4 |
Implement htmx child select (dynamic population) |
Done (pre-ADR-030) — |
5 |
Fix dashboard stat card API path (same bug) |
Done (pre-ADR-030) — |
6 |
E2E test update |
Done (pre-ADR-030) — |
7 |
Documentation, commit, push, MR |
Done (pre-ADR-030) — work landed incrementally via prior MRs; this plan archived 2026-04-17 (plan-hygiene sweep) |
Issues: TBD (work shipped incrementally, not tracked to a single issue)
Branch: feature/placement-picker (unused — implementation landed on other branches)
Context
The new placement form (/placement/new) requires users to type raw UUIDs for Case ID and Child ID. An implementation was attempted in !42 but reverted because the case select dropdown rendered empty.
Root Cause (Investigated)
The previous attempt used the wrong API path. The craig-cases service nests all routes under /cases/, so:
-
Wrong path:
/v1/cases?status=open&page=1&per_page=200 -
Correct path:
/v1/cases/cases?page=1&per_page=200
The fetch_page() function in routes/mod.rs silently returns empty data on API errors via serde_json::from_value(v).unwrap_or_default(). The 404 response from the wrong path was swallowed — no logging, no error, just an empty select dropdown.
Same bug exists in dashboard.rs: Line 37 uses /v1/cases?page=1&per_page=1 (wrong) instead of /v1/cases/cases?page=1&per_page=1. The "Total Cases" stat card silently shows 0 or a dash because the API call 404s.
The child lookup path also had the wrong pattern. The correct approach:
-
Case household:
GET /v1/cases/cases/{case_id}/householdreturnsVec<CaseHousehold>withperson_id,role,first_name,last_name -
Filter by
role == "child"client-side -
No separate
/personsendpoint needed — household already has names
Design
Step 2: Fix fetch_page logging
File: services/craig-web/src/routes/mod.rs — line 56
Replace silent unwrap_or_default() with logging:
// BEFORE:
Ok(v) => serde_json::from_value(v).unwrap_or_default(),
// AFTER:
Ok(v) => match serde_json::from_value(v) {
Ok(page) => page,
Err(e) => {
tracing::warn!(path, error = %e, "API response deserialization failed");
PageResponse::default()
}
},
Step 3: Case select dropdown
File: services/craig-web/src/routes/placement.rs
Add to NewPlacementTemplate:
cases: Vec<CaseOption>,
Add struct:
#[allow(dead_code)]
#[derive(Deserialize, Default, Clone)]
pub struct CaseOption {
pub id: Uuid,
pub case_number: String,
pub admin_unit: String,
}
In new_placement_form handler, fetch cases using CORRECT path:
let cases_path = "/v1/cases/cases?page=1&per_page=200";
let (homes_resp, cases_resp) = tokio::join!(
fetch_page::<FosterHomeView>(&state.api, &state.config.placement_url, homes_path, token),
fetch_page::<CaseOption>(&state.api, &state.config.cases_url, cases_path, token),
);
File: services/craig-web/templates/placement/new_placement.html
Replace Case ID text input with select:
<select class="form-select" id="case_id" name="case_id" required
hx-get="/placement/api/case-children"
hx-target="#child_id"
hx-swap="innerHTML"
hx-trigger="change"
hx-include="[name='case_id']">
<option value="">— Select Case —</option>
{% for c in cases %}
<option value="{{ c.id }}">{{ c.case_number }} ({{ c.admin_unit }})</option>
{% endfor %}
</select>
Step 4: htmx child select
Route: GET /placement/api/case-children?case_id={uuid}
Fetch household using CORRECT path, filter to children, return <option> HTML:
pub async fn case_children_options(
session: Session,
State(state): State<AppState>,
Query(params): Query<CaseChildrenParams>,
) -> axum::response::Response {
let user = get_session_user(&session).await.unwrap_or_default();
let token = &user.access_token;
// Correct path: /v1/cases/cases/{id}/household
let path = format!("/v1/cases/cases/{}/household", params.case_id);
let members: Vec<HouseholdMemberView> = match state
.api
.get(&state.config.cases_url, &path, token)
.await
{
Ok(v) => serde_json::from_value(v).unwrap_or_default(),
Err(_) => Vec::new(),
};
// Filter to children only
let children: Vec<_> = members.iter().filter(|m| m.role == "child").collect();
let html = if children.is_empty() {
r#"<option value="">— No children found —</option>"#.to_string()
} else {
children.iter().map(|c| {
format!(r#"<option value="{}">{} {}</option>"#,
c.person_id, c.first_name, c.last_name)
}).collect()
};
axum::response::Html(html).into_response()
}
Register route in main.rs:
.route("/placement/api/case-children", get(routes::placement::case_children_options))
Step 5: Fix dashboard stats path
File: services/craig-web/src/routes/dashboard.rs — line 37
// BEFORE (wrong):
.get(cases_url, "/v1/cases?page=1&per_page=1", token),
// AFTER (correct):
.get(cases_url, "/v1/cases/cases?page=1&per_page=1", token),
Also fix the investigations path if wrong:
// Check: is the path /v1/cases/investigations or /v1/investigations?
Step 6: E2E test update
File: tests/e2e/specs/placement.spec.ts
// Change fill() to selectOption() for case_id
await page.locator('#case_id').selectOption(SEED.cases.case4_gwinnett.id);
// Wait for htmx child options to load
await expect(page.locator('#child_id option').nth(1)).toBeAttached({ timeout: 5000 });
// Select child
await page.locator('#child_id').selectOption(SEED.persons.keiraRolfson.id);
Verification
-
Navigate to
/placement/new— case dropdown shows cases with case numbers -
Select a case — child dropdown populates via htmx with children’s names
-
Submit form — placement created successfully
-
Dashboard "Total Cases" stat shows correct count (not dash/0)
-
E2E test passes 5x locally
-
No existing tests broken
Files Touched
| File | Change |
|---|---|
|
Add deserialization error logging to fetch_page |
|
Add CaseOption, case fetch, htmx child handler |
|
Fix API path /v1/cases → /v1/cases/cases |
|
Add /placement/api/case-children route |
|
Replace text inputs with selects |
|
Update fill() → selectOption() |