Fix FastHTML empty value attribute omission in select options

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-08 09:31:11 +00:00
parent 29fbe68c73
commit fb59ef72a8
2 changed files with 27 additions and 12 deletions

View File

@@ -426,10 +426,13 @@ async def animal_promote(request: Request):
form = await request.form() form = await request.form()
# Extract form data # Extract form data
# Note: "-" is used as sentinel for "no change" because FastHTML omits empty value attributes
animal_id = form.get("animal_id", "") animal_id = form.get("animal_id", "")
nickname = form.get("nickname", "") or None nickname = form.get("nickname", "") or None
sex = form.get("sex", "") or None sex_raw = form.get("sex", "")
repro_status = form.get("repro_status", "") or None 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 distinguishing_traits = form.get("distinguishing_traits", "") or None
notes = form.get("notes", "") or None notes = form.get("notes", "") or None
nonce = form.get("nonce") nonce = form.get("nonce")
@@ -1034,10 +1037,14 @@ async def animal_attrs(request: Request, session):
form = await request.form() form = await request.form()
# Extract form data # Extract form data
# Note: "-" is used as sentinel for "no change" because FastHTML omits empty value attributes
filter_str = form.get("filter", "") filter_str = form.get("filter", "")
sex = form.get("sex", "").strip() or None sex_raw = form.get("sex", "").strip()
life_stage = form.get("life_stage", "").strip() or None sex = None if sex_raw in ("", "-") else sex_raw
repro_status = form.get("repro_status", "").strip() or None 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", "") roster_hash = form.get("roster_hash", "")
confirmed = form.get("confirmed", "") == "true" confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce") nonce = form.get("nonce")
@@ -1277,7 +1284,9 @@ async def animal_outcome(request: Request, session):
nonce = form.get("nonce") nonce = form.get("nonce")
# Yield item fields # 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_unit = form.get("yield_unit", "").strip() or None
yield_quantity_str = form.get("yield_quantity", "").strip() yield_quantity_str = form.get("yield_quantity", "").strip()
yield_weight_str = form.get("yield_weight_kg", "").strip() yield_weight_str = form.get("yield_weight_kg", "").strip()

View File

@@ -443,8 +443,9 @@ def promote_form(
action_text = "Renaming" if is_rename else "Promoting" action_text = "Renaming" if is_rename else "Promoting"
# Build sex options (optional - can refine current value) # Build sex options (optional - can refine current value)
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
sexes = [ sexes = [
("", "Keep current"), ("-", "Keep current"),
("female", "Female"), ("female", "Female"),
("male", "Male"), ("male", "Male"),
] ]
@@ -453,8 +454,9 @@ def promote_form(
sex_options.append(Option(label, value=code, selected=code == selected_sex)) sex_options.append(Option(label, value=code, selected=code == selected_sex))
# Build repro status options (optional) - must match ReproStatus enum # Build repro status options (optional) - must match ReproStatus enum
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
repro_statuses = [ repro_statuses = [
("", "Keep current"), ("-", "Keep current"),
("intact", "Intact"), ("intact", "Intact"),
("wether", "Wether (castrated male)"), ("wether", "Wether (castrated male)"),
("spayed", "Spayed (female)"), ("spayed", "Spayed (female)"),
@@ -1032,24 +1034,27 @@ def attrs_form(
) )
# Build sex options # Build sex options
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
sex_options = [ sex_options = [
Option("No change", value="", selected=True), Option("No change", value="-", selected=True),
Option("Female", value="female"), Option("Female", value="female"),
Option("Male", value="male"), Option("Male", value="male"),
Option("Unknown", value="unknown"), Option("Unknown", value="unknown"),
] ]
# Build life stage options # Build life stage options
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
life_stage_options = [ life_stage_options = [
Option("No change", value="", selected=True), Option("No change", value="-", selected=True),
Option("Hatchling", value="hatchling"), Option("Hatchling", value="hatchling"),
Option("Juvenile", value="juvenile"), Option("Juvenile", value="juvenile"),
Option("Adult", value="adult"), Option("Adult", value="adult"),
] ]
# Build repro status options (intact, wether, spayed, unknown) # Build repro status options (intact, wether, spayed, unknown)
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
repro_status_options = [ repro_status_options = [
Option("No change", value="", selected=True), Option("No change", value="-", selected=True),
Option("Intact", value="intact"), Option("Intact", value="intact"),
Option("Wether (castrated male)", value="wether"), Option("Wether (castrated male)", value="wether"),
Option("Spayed (female)", value="spayed"), Option("Spayed (female)", value="spayed"),
@@ -1293,7 +1298,8 @@ def outcome_form(
] ]
# Build product options for yield items # 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: for code, name in products:
product_options.append(Option(f"{name} ({code})", value=code)) product_options.append(Option(f"{name} ({code})", value=code))