From fb59ef72a888aa340334e498827065b5853f5a38 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 8 Jan 2026 09:31:11 +0000 Subject: [PATCH] Fix FastHTML empty value attribute omission in select options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastHTML omits the value attribute when value="" (empty string), causing browsers to use the option's text content as the submitted value. This made forms send "Keep current" or "No change" text instead of empty string, failing Pydantic enum validation. Fixed by using "-" as a sentinel value instead of "" for "no change" options, and updating route handlers to treat "-" as None. Affected forms: - Promote form (sex, repro_status) - Update attributes form (sex, life_stage, repro_status) - Outcome form (yield_product_code) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/web/routes/actions.py | 21 +++++++++++++++------ src/animaltrack/web/templates/actions.py | 18 ++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py index 16025f6..153f908 100644 --- a/src/animaltrack/web/routes/actions.py +++ b/src/animaltrack/web/routes/actions.py @@ -426,10 +426,13 @@ async def animal_promote(request: Request): form = await request.form() # Extract form data + # Note: "-" is used as sentinel for "no change" because FastHTML omits empty value attributes animal_id = form.get("animal_id", "") nickname = form.get("nickname", "") or None - sex = form.get("sex", "") or None - repro_status = form.get("repro_status", "") or None + sex_raw = form.get("sex", "") + sex = None if sex_raw in ("", "-") else sex_raw + repro_raw = form.get("repro_status", "") + repro_status = None if repro_raw in ("", "-") else repro_raw distinguishing_traits = form.get("distinguishing_traits", "") or None notes = form.get("notes", "") or None nonce = form.get("nonce") @@ -1034,10 +1037,14 @@ async def animal_attrs(request: Request, session): form = await request.form() # Extract form data + # Note: "-" is used as sentinel for "no change" because FastHTML omits empty value attributes filter_str = form.get("filter", "") - sex = form.get("sex", "").strip() or None - life_stage = form.get("life_stage", "").strip() or None - repro_status = form.get("repro_status", "").strip() or None + sex_raw = form.get("sex", "").strip() + sex = None if sex_raw in ("", "-") else sex_raw + life_stage_raw = form.get("life_stage", "").strip() + life_stage = None if life_stage_raw in ("", "-") else life_stage_raw + repro_raw = form.get("repro_status", "").strip() + repro_status = None if repro_raw in ("", "-") else repro_raw roster_hash = form.get("roster_hash", "") confirmed = form.get("confirmed", "") == "true" nonce = form.get("nonce") @@ -1277,7 +1284,9 @@ async def animal_outcome(request: Request, session): nonce = form.get("nonce") # Yield item fields - yield_product_code = form.get("yield_product_code", "").strip() or None + # Note: "-" is used as sentinel for "no selection" because FastHTML omits empty value attributes + yield_product_raw = form.get("yield_product_code", "").strip() + yield_product_code = None if yield_product_raw in ("", "-") else yield_product_raw yield_unit = form.get("yield_unit", "").strip() or None yield_quantity_str = form.get("yield_quantity", "").strip() yield_weight_str = form.get("yield_weight_kg", "").strip() diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py index c3c63ab..69dd138 100644 --- a/src/animaltrack/web/templates/actions.py +++ b/src/animaltrack/web/templates/actions.py @@ -443,8 +443,9 @@ def promote_form( action_text = "Renaming" if is_rename else "Promoting" # Build sex options (optional - can refine current value) + # Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes sexes = [ - ("", "Keep current"), + ("-", "Keep current"), ("female", "Female"), ("male", "Male"), ] @@ -453,8 +454,9 @@ def promote_form( sex_options.append(Option(label, value=code, selected=code == selected_sex)) # Build repro status options (optional) - must match ReproStatus enum + # Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes repro_statuses = [ - ("", "Keep current"), + ("-", "Keep current"), ("intact", "Intact"), ("wether", "Wether (castrated male)"), ("spayed", "Spayed (female)"), @@ -1032,24 +1034,27 @@ def attrs_form( ) # Build sex options + # Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes sex_options = [ - Option("No change", value="", selected=True), + Option("No change", value="-", selected=True), Option("Female", value="female"), Option("Male", value="male"), Option("Unknown", value="unknown"), ] # Build life stage options + # Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes life_stage_options = [ - Option("No change", value="", selected=True), + Option("No change", value="-", selected=True), Option("Hatchling", value="hatchling"), Option("Juvenile", value="juvenile"), Option("Adult", value="adult"), ] # Build repro status options (intact, wether, spayed, unknown) + # Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes repro_status_options = [ - Option("No change", value="", selected=True), + Option("No change", value="-", selected=True), Option("Intact", value="intact"), Option("Wether (castrated male)", value="wether"), Option("Spayed (female)", value="spayed"), @@ -1293,7 +1298,8 @@ def outcome_form( ] # Build product options for yield items - product_options = [Option("Select product...", value="", selected=True)] + # Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes + product_options = [Option("Select product...", value="-", selected=True)] for code, name in products: product_options.append(Option(f"{name} ({code})", value=code))