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) — services/craig-web/src/routes/mod.rs fetch_page emits tracing::warn!(path, error = %e, "API response deserialization failed") on deserialization failures

3

Implement case select dropdown

Done (pre-ADR-030) — services/craig-web/templates/placement/new_placement.html renders the case <select> with htmx attributes; CaseOption struct at services/craig-web/src/routes/placement/mod.rs

4

Implement htmx child select (dynamic population)

Done (pre-ADR-030) — case_children_options handler at services/craig-web/src/routes/placement/placements.rs serves /placement/api/case-children, wired via hx-get + hx-trigger="change"

5

Fix dashboard stat card API path (same bug)

Done (pre-ADR-030) — services/craig-web/src/routes/dashboard.rs uses /v1/cases/cases?page=1&per_page=1

6

E2E test update

Done (pre-ADR-030) — tests/e2e/specs/placement.spec.ts uses page.selectOption() with htmx-wait pattern

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}/household returns Vec<CaseHousehold> with person_id, role, first_name, last_name

  • Filter by role == "child" client-side

  • No separate /persons endpoint 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

  1. Navigate to /placement/new — case dropdown shows cases with case numbers

  2. Select a case — child dropdown populates via htmx with children’s names

  3. Submit form — placement created successfully

  4. Dashboard "Total Cases" stat shows correct count (not dash/0)

  5. E2E test passes 5x locally

  6. No existing tests broken

Files Touched

File Change

services/craig-web/src/routes/mod.rs

Add deserialization error logging to fetch_page

services/craig-web/src/routes/placement.rs

Add CaseOption, case fetch, htmx child handler

services/craig-web/src/routes/dashboard.rs

Fix API path /v1/cases → /v1/cases/cases

services/craig-web/src/main.rs

Add /placement/api/case-children route

services/craig-web/templates/placement/new_placement.html

Replace text inputs with selects

tests/e2e/specs/placement.spec.ts

Update fill() → selectOption()

Documentation Updates

  • CHANGELOG.adoc

Edit this page · latest