Fix MonsterUI Select bug, UI improvements, enable animal rename

- Replace broken MonsterUI LabelSelect with raw HTML Select elements
  (was sending label text instead of value attribute)
- Fix wrong ReproStatus options in promote form (use enum values)
- Add spacing between name and details in animal selection list
- Fix registry filter layout, add Clear button
- Use phonetic ID in animal details panel title
- Change feed price input from cents to euros
- Allow renaming already-identified animals (remove identified check)
- Fix FeedGiven 422 error (same MonsterUI Select bug)

🤖 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-05 15:57:09 +00:00
parent 14bf2fa4ae
commit ad1f91098b
7 changed files with 115 additions and 102 deletions

View File

@@ -408,13 +408,13 @@ def promote_index(request: Request, animal_id: str):
if animal.status != "alive": if animal.status != "alive":
return HTMLResponse(content="Only alive animals can be promoted", status_code=400) return HTMLResponse(content="Only alive animals can be promoted", status_code=400)
if animal.identified: # Title depends on whether animal is already identified (rename vs promote)
return HTMLResponse(content="Animal is already identified", status_code=400) title = "Rename Animal - AnimalTrack" if animal.identified else "Promote Animal - AnimalTrack"
return render_page( return render_page(
request, request,
promote_form(animal), promote_form(animal),
title="Promote Animal - AnimalTrack", title=title,
active_nav=None, active_nav=None,
) )
@@ -448,9 +448,6 @@ async def animal_promote(request: Request):
if animal.status != "alive": if animal.status != "alive":
return _render_promote_error(request, animal, "Only alive animals can be promoted", form) return _render_promote_error(request, animal, "Only alive animals can be promoted", form)
if animal.identified:
return _render_promote_error(request, animal, "Animal is already identified", form)
# Create payload # Create payload
try: try:
payload = AnimalPromotedPayload( payload = AnimalPromotedPayload(

View File

@@ -273,7 +273,7 @@ async def feed_purchased(request: Request, session):
feed_type_code = form.get("feed_type_code", "") feed_type_code = form.get("feed_type_code", "")
bag_size_kg_str = form.get("bag_size_kg", "0") bag_size_kg_str = form.get("bag_size_kg", "0")
bags_count_str = form.get("bags_count", "0") bags_count_str = form.get("bags_count", "0")
bag_price_cents_str = form.get("bag_price_cents", "0") bag_price_euros_str = form.get("bag_price_euros", "0")
vendor = form.get("vendor") or None vendor = form.get("vendor") or None
notes = form.get("notes") or None notes = form.get("notes") or None
nonce = form.get("nonce") nonce = form.get("nonce")
@@ -329,9 +329,10 @@ async def feed_purchased(request: Request, session):
"Bags count must be at least 1", "Bags count must be at least 1",
) )
# Validate bag_price_cents # Validate bag_price_euros and convert to cents
try: try:
bag_price_cents = int(bag_price_cents_str) bag_price_euros = float(bag_price_euros_str)
bag_price_cents = int(round(bag_price_euros * 100))
except ValueError: except ValueError:
return _render_purchase_error( return _render_purchase_error(
request, request,

View File

@@ -4,7 +4,7 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Span from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Select, Span
from monsterui.all import ( from monsterui.all import (
Alert, Alert,
AlertT, AlertT,
@@ -436,6 +436,11 @@ def promote_form(
""" """
display_id = f"{animal.animal_id[:8]}..." display_id = f"{animal.animal_id[:8]}..."
# Title and action text depend on whether animal is already identified
is_rename = animal.identified
form_title = "Rename Animal" if is_rename else "Promote Animal"
action_text = "Renaming" if is_rename else "Promoting"
# Build sex options (optional - can refine current value) # Build sex options (optional - can refine current value)
sexes = [ sexes = [
("", "Keep current"), ("", "Keep current"),
@@ -446,13 +451,13 @@ def promote_form(
for code, label in sexes: for code, label in sexes:
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) # Build repro status options (optional) - must match ReproStatus enum
repro_statuses = [ repro_statuses = [
("", "Unknown"), ("", "Keep current"),
("breeding", "Breeding"), ("intact", "Intact"),
("non_breeding", "Non-Breeding"), ("wether", "Wether (castrated male)"),
("broody", "Broody"), ("spayed", "Spayed (female)"),
("molting", "Molting"), ("unknown", "Unknown"),
] ]
repro_status_options = [] repro_status_options = []
for code, label in repro_statuses: for code, label in repro_statuses:
@@ -466,36 +471,34 @@ def promote_form(
error_component = Alert(error, cls=AlertT.warning) error_component = Alert(error, cls=AlertT.warning)
return Form( return Form(
H2("Promote Animal", cls="text-xl font-bold mb-4"), H2(form_title, cls="text-xl font-bold mb-4"),
# Animal info # Animal info
Div( Div(
P(f"Promoting: {display_id}", cls="text-sm text-stone-400"), P(f"{action_text}: {display_id}", cls="text-sm text-stone-400"),
P(f"Species: {animal.species_code}, Sex: {animal.sex}", cls="text-sm text-stone-400"), P(f"Species: {animal.species_code}, Sex: {animal.sex}", cls="text-sm text-stone-400"),
cls="p-3 bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-800 rounded-md mb-4",
), ),
# Error message if present # Error message if present
error_component, error_component,
# Nickname input (optional) # Nickname input - label changes for rename vs promote
LabelInput( LabelInput(
"Nickname (optional)", "New Name" if is_rename else "Nickname (optional)",
id="nickname", id="nickname",
name="nickname", name="nickname",
value=nickname_value, value=nickname_value or (animal.nickname or ""),
placeholder="Give this animal a name", placeholder="Enter a name for this animal",
), ),
# Sex refinement dropdown (optional) # Sex refinement dropdown (optional) - using raw Select to fix value handling
LabelSelect( Div(
*sex_options, FormLabel("Refine Sex (optional)", _for="sex"),
label="Refine Sex (optional)", Select(*sex_options, name="sex", id="sex", cls="uk-select"),
id="sex", cls="space-y-2",
name="sex",
), ),
# Repro status dropdown (optional) # Repro status dropdown (optional) - using raw Select to fix value handling
LabelSelect( Div(
*repro_status_options, FormLabel("Reproductive Status", _for="repro_status"),
label="Reproductive Status", Select(*repro_status_options, name="repro_status", id="repro_status", cls="uk-select"),
id="repro_status", cls="space-y-2",
name="repro_status",
), ),
# Distinguishing traits (optional) # Distinguishing traits (optional)
LabelTextArea( LabelTextArea(
@@ -515,8 +518,12 @@ def promote_form(
# Hidden fields # Hidden fields
Hidden(name="animal_id", value=animal.animal_id), Hidden(name="animal_id", value=animal.animal_id),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button - text changes for rename vs promote
Button("Promote to Identified", type="submit", cls=ButtonT.primary), Button(
"Save Changes" if is_rename else "Promote to Identified",
type="submit",
cls=ButtonT.primary,
),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
method="post", method="post",
@@ -1071,24 +1078,21 @@ def attrs_form(
), ),
# Selection container - updated via HTMX when filter changes # Selection container - updated via HTMX when filter changes
selection_container, selection_container,
# Attribute dropdowns # Attribute dropdowns - using raw Select to fix value handling
LabelSelect( Div(
*sex_options, FormLabel("Sex", _for="sex"),
label="Sex", Select(*sex_options, name="sex", id="sex", cls="uk-select"),
id="sex", cls="space-y-2",
name="sex",
), ),
LabelSelect( Div(
*life_stage_options, FormLabel("Life Stage", _for="life_stage"),
label="Life Stage", Select(*life_stage_options, name="life_stage", id="life_stage", cls="uk-select"),
id="life_stage", cls="space-y-2",
name="life_stage",
), ),
LabelSelect( Div(
*repro_status_options, FormLabel("Reproductive Status", _for="repro_status"),
label="Reproductive Status", Select(*repro_status_options, name="repro_status", id="repro_status", cls="uk-select"),
id="repro_status", cls="space-y-2",
name="repro_status",
), ),
# Optional notes # Optional notes
LabelTextArea( LabelTextArea(

View File

@@ -7,6 +7,7 @@ from typing import Any
from fasthtml.common import H2, H3, A, Div, Li, P, Span, Ul from fasthtml.common import H2, H3, A, Div, Li, P, Span, Ul
from monsterui.all import Button, ButtonT, Card, Grid from monsterui.all import Button, ButtonT, Card, Grid
from animaltrack.id_gen import format_animal_id
from animaltrack.repositories.animal_timeline import ( from animaltrack.repositories.animal_timeline import (
AnimalDetail, AnimalDetail,
MergeInfo, MergeInfo,
@@ -61,7 +62,7 @@ def back_to_registry_link() -> Div:
def animal_header_card(animal: AnimalDetail, merge_info: MergeInfo | None) -> Card: def animal_header_card(animal: AnimalDetail, merge_info: MergeInfo | None) -> Card:
"""Header card with animal summary.""" """Header card with animal summary."""
display_name = animal.nickname or f"{animal.animal_id[:8]}..." display_name = format_animal_id(animal.animal_id, animal.nickname)
status_badge = status_badge_component(animal.status) status_badge = status_badge_component(animal.status)
tags_display = ( tags_display = (
@@ -160,10 +161,11 @@ def quick_actions_card(animal: AnimalDetail) -> Card:
href=f"/actions/tag-add?filter=animal_id:{animal.animal_id}", href=f"/actions/tag-add?filter=animal_id:{animal.animal_id}",
) )
) )
if not animal.identified: # Show "Promote" for unidentified animals, "Rename" for identified ones
promote_label = "Rename" if animal.identified else "Promote"
actions.append( actions.append(
A( A(
Button("Promote", cls=ButtonT.default + " w-full"), Button(promote_label, cls=ButtonT.default + " w-full"),
href=f"/actions/promote/{animal.animal_id}", href=f"/actions/promote/{animal.animal_id}",
) )
) )

View File

@@ -47,9 +47,9 @@ def animal_checkbox_list(
cls="uk-checkbox mr-2", cls="uk-checkbox mr-2",
hx_on_change="updateSelectionCount()", hx_on_change="updateSelectionCount()",
), ),
Span(display_name, cls="text-stone-200"), Span(display_name, cls="text-stone-200 mr-1"),
Span( Span(
f" ({animal.species_code}, {sex_code}, {stage_abbr}, {animal.location_name})", f"({animal.species_code}, {sex_code}, {stage_abbr}, {animal.location_name})",
cls="text-stone-500 text-sm", cls="text-stone-500 text-sm",
), ),
cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer", cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer",

View File

@@ -4,12 +4,12 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Select, Ul
from monsterui.all import ( from monsterui.all import (
Button, Button,
ButtonT, ButtonT,
FormLabel,
LabelInput, LabelInput,
LabelSelect,
LabelTextArea, LabelTextArea,
TabContainer, TabContainer,
) )
@@ -170,19 +170,17 @@ def give_feed_form(
H2("Give Feed", cls="text-xl font-bold mb-4"), H2("Give Feed", cls="text-xl font-bold mb-4"),
error_component, error_component,
warning_component, warning_component,
# Location dropdown # Location dropdown - using raw Select to fix value handling
LabelSelect( Div(
*location_options, FormLabel("Location", _for="location_id"),
label="Location", Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
id="location_id", cls="space-y-2",
name="location_id",
), ),
# Feed type dropdown # Feed type dropdown - using raw Select to fix value handling
LabelSelect( Div(
*feed_type_options, FormLabel("Feed Type", _for="feed_type_code"),
label="Feed Type", Select(*feed_type_options, name="feed_type_code", id="feed_type_code", cls="uk-select"),
id="feed_type_code", cls="space-y-2",
name="feed_type_code",
), ),
# Amount input # Amount input
LabelInput( LabelInput(
@@ -247,12 +245,16 @@ def purchase_feed_form(
return Form( return Form(
H2("Purchase Feed", cls="text-xl font-bold mb-4"), H2("Purchase Feed", cls="text-xl font-bold mb-4"),
error_component, error_component,
# Feed type dropdown # Feed type dropdown - using raw Select to fix value handling
LabelSelect( Div(
FormLabel("Feed Type", _for="purchase_feed_type_code"),
Select(
*feed_type_options, *feed_type_options,
label="Feed Type",
id="purchase_feed_type_code",
name="feed_type_code", name="feed_type_code",
id="purchase_feed_type_code",
cls="uk-select",
),
cls="space-y-2",
), ),
# Bag size # Bag size
LabelInput( LabelInput(
@@ -276,15 +278,15 @@ def purchase_feed_form(
value="1", value="1",
required=True, required=True,
), ),
# Price per bag (cents) # Price per bag (euros)
LabelInput( LabelInput(
"Price per Bag (cents)", "Price per Bag ()",
id="bag_price_cents", id="bag_price_euros",
name="bag_price_cents", name="bag_price_euros",
type="number", type="number",
min="0", min="0",
step="1", step="0.01",
placeholder="e.g., 2400 for 24.00", placeholder="e.g., 24.00",
required=True, required=True,
), ),
# Optional vendor # Optional vendor

View File

@@ -5,8 +5,8 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from fasthtml.common import H2, A, Div, Form, P, Span, Table, Tbody, Td, Th, Thead, Tr from fasthtml.common import H2, A, Div, Form, Input, P, Span, Table, Tbody, Td, Th, Thead, Tr
from monsterui.all import Button, ButtonT, Grid, LabelInput from monsterui.all import Button, ButtonT, FormLabel, Grid
from animaltrack.id_gen import format_animal_id from animaltrack.id_gen import format_animal_id
from animaltrack.models.reference import Location, Species from animaltrack.models.reference import Location, Species
@@ -75,21 +75,28 @@ def registry_header(filter_str: str, total_count: int) -> Div:
), ),
# Filter form - full width, prominent # Filter form - full width, prominent
Form( Form(
# Label above the input row
FormLabel("Filter", _for="filter", cls="mb-2 block"),
Div( Div(
# Filter input - wider with flex-grow # Filter input - takes most of the width
Div( Input(
LabelInput(
"Filter",
id="filter", id="filter",
name="filter", name="filter",
value=filter_str, value=filter_str,
placeholder='species:duck status:alive location:"Strip 1"', placeholder='species:duck status:alive location:"Strip 1"',
cls="uk-input flex-1",
), ),
cls="flex-1 min-w-0", # min-w-0 prevents flex overflow # Apply button
), Button("Apply", type="submit", cls=f"{ButtonT.primary} px-4"),
# Apply button - fixed size # Clear button (only shown if filter is active)
Button("Apply", type="submit", cls=f"{ButtonT.primary} shrink-0"), A(
cls="flex gap-3 items-end", "Clear",
href="/registry",
cls="px-3 py-2 text-stone-400 hover:text-stone-200",
)
if filter_str
else None,
cls="flex gap-2 items-center",
), ),
action="/registry", action="/registry",
method="get", method="get",