Compare commits

...

9 Commits

Author SHA1 Message Date
5be8da96f2 Fix 405 error after event deletion via HX-Push-Url header
All checks were successful
Deploy / deploy (push) Successful in 2m39s
When HTMX boosted forms submit via POST, the browser URL wasn't being
updated correctly. This caused window.location.reload() after event
deletion to reload the action URL (e.g., /actions/feed-given) instead
of the display URL (e.g., /feed), resulting in a 405 Method Not Allowed.

The fix adds a render_page_post() helper that returns FT components
with an HttpHeader("HX-Push-Url", push_url). This tells HTMX to update
the browser history to the correct URL after successful form submission.

Updated routes:
- /actions/feed-given -> push /feed
- /actions/feed-purchased -> push /feed
- /actions/product-collected -> push /
- /actions/product-sold -> push /
- /actions/animal-move -> push /move
- /actions/animal-cohort -> push /actions/cohort
- /actions/hatch-recorded -> push /actions/hatch
- /actions/animal-tag-add -> push /actions/tag-add
- /actions/animal-tag-end -> push /actions/tag-end
- /actions/animal-attrs -> push /actions/attrs
- /actions/animal-outcome -> push /actions/outcome
- /actions/animal-status-correct -> push /actions/status-correct

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:23:50 +00:00
803169816b Replace onclick navigation with proper links
Converts cancel buttons that use onclick="window.location.href='...'" to
proper A tags with href. This improves accessibility (keyboard navigation,
right-click options) and semantics while maintaining the same button styling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:25:02 +00:00
7315e552e3 Extract DateTime picker to static JS file
Moves ~50 lines of inline JavaScript from event_datetime_field() to a
static file. The component now uses data attributes for element binding
and global functions (toggleDatetimePicker, updateDatetimeTs) from the
static JS file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:22:41 +00:00
4e78b79745 Extract slide-over script to shared component
Creates slide_over_script() in shared_scripts.py that generates JavaScript
for slide-over panels with open/close functions. EventSlideOverScript and
SidebarScript now use this shared function, reducing duplicated JS logic.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:20:18 +00:00
fc4c2a8e40 Extract common diff_confirmation_panel() for selection mismatch UI
Refactors 5 nearly-identical diff_panel functions into a single reusable
component. Each specific diff_panel function now delegates to the common
function with action-specific parameters.

Reduces ~300 lines of duplicated code to ~100 lines of shared logic.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:17:51 +00:00
b2132a8ef5 Add accessibility attributes for screen readers
Improve accessibility by adding ARIA attributes to interactive
components:

nav.py:
- Menu button: aria_label="Open navigation menu"

sidebar.py:
- Close button: aria_label="Close menu"
- Drawer panel: role="dialog", aria_label="Navigation menu"

base.py:
- Toast container: aria_live="polite" (announces toasts)
- Event slide-over: role="dialog", aria_label="Event details"

These changes help screen readers properly announce interactive
elements and their purposes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:12:46 +00:00
a87b5cbac6 Preserve form field values in eggs.py on validation errors
When a validation error occurs in the harvest or sell forms, the
entered field values are now preserved and redisplayed to the user.
This prevents the frustration of having to re-enter all values after
a single field fails validation.

Template changes (eggs.py):
- Added default_* parameters to harvest_form and sell_form
- Updated LabelInput/LabelTextArea fields to use these values

Route changes (routes/eggs.py):
- Updated _render_harvest_error to accept quantity and notes
- Updated _render_sell_error to accept quantity, total_price_cents,
  buyer, and notes
- All error return calls now pass form values through

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:10:54 +00:00
b09d3088eb Add loading state indicators to all form submit buttons
Add hx_disabled_elt="this" to submit buttons across all forms to
disable them during form submission, preventing double-clicks and
providing visual feedback that the action is processing.

Buttons updated:
- actions.py: promote, cohort, hatch, tag-add, tag-end, attrs,
  outcome, status-correct forms and their diff_panel confirmations
- eggs.py: collect and sell forms
- feed.py: give and purchase forms
- locations.py: create and rename forms
- move.py: move form and diff_panel confirmation
- products.py: create form
- registry.py: filter apply button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:07:15 +00:00
2fc98155c3 Replace LabelSelect with raw Select to fix MonsterUI value bug
MonsterUI LabelSelect has a confirmed bug where it sends the option's
label text instead of the value attribute on form submission. This was
causing 422 validation errors in forms.

- Replace all LabelSelect usages with raw Div(FormLabel, Select) pattern
- Add comments documenting the MonsterUI bug workaround
- Matches pattern already used in feed.py since commit ad1f910

Files modified: eggs.py, move.py, actions.py, products.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 11:24:34 +00:00
17 changed files with 684 additions and 516 deletions

View File

@@ -0,0 +1,53 @@
/**
* Datetime Picker Component
*
* Provides toggle and conversion functionality for backdating events.
* Uses data attributes to identify related elements.
*
* Expected HTML structure:
* - Toggle element: data-datetime-toggle="<field_id>"
* - Picker container: data-datetime-picker="<field_id>"
* - Input element: data-datetime-input="<field_id>"
* - Hidden ts_utc field: data-datetime-ts="<field_id>"
*/
/**
* Toggle the datetime picker visibility.
* @param {string} fieldId - The unique field ID prefix.
*/
function toggleDatetimePicker(fieldId) {
var picker = document.querySelector('[data-datetime-picker="' + fieldId + '"]');
var input = document.querySelector('[data-datetime-input="' + fieldId + '"]');
var tsField = document.querySelector('[data-datetime-ts="' + fieldId + '"]');
var toggle = document.querySelector('[data-datetime-toggle="' + fieldId + '"]');
if (!picker || !toggle) return;
if (picker.style.display === 'none') {
picker.style.display = 'block';
toggle.textContent = 'Use current time';
} else {
picker.style.display = 'none';
toggle.textContent = 'Set custom date';
if (input) input.value = '';
if (tsField) tsField.value = '0';
}
}
/**
* Update the hidden ts_utc field when datetime input changes.
* @param {string} fieldId - The unique field ID prefix.
*/
function updateDatetimeTs(fieldId) {
var input = document.querySelector('[data-datetime-input="' + fieldId + '"]');
var tsField = document.querySelector('[data-datetime-ts="' + fieldId + '"]');
if (!tsField) return;
if (input && input.value) {
var date = new Date(input.value);
tsField.value = date.getTime().toString();
} else {
tsField.value = '0';
}
}

View File

@@ -37,7 +37,7 @@ from animaltrack.selection import compute_roster_hash, parse_filter, resolve_fil
from animaltrack.selection.validation import SelectionContext, validate_selection from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.auth import UserRole, require_role from animaltrack.web.auth import UserRole, require_role
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page, render_page_post
from animaltrack.web.templates.actions import ( from animaltrack.web.templates.actions import (
attrs_diff_panel, attrs_diff_panel,
attrs_form, attrs_form,
@@ -206,9 +206,11 @@ async def animal_cohort(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
cohort_form(locations, species_list), cohort_form(locations, species_list),
push_url="/actions/cohort",
title="Create Cohort - AnimalTrack", title="Create Cohort - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -349,9 +351,11 @@ async def hatch_recorded(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
hatch_form(locations, species_list), hatch_form(locations, species_list),
push_url="/actions/hatch",
title="Record Hatch - AnimalTrack", title="Record Hatch - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -690,9 +694,11 @@ async def animal_tag_add(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
tag_add_form(), tag_add_form(),
push_url="/actions/tag-add",
title="Add Tag - AnimalTrack", title="Add Tag - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -939,9 +945,11 @@ async def animal_tag_end(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
tag_end_form(), tag_end_form(),
push_url="/actions/tag-end",
title="End Tag - AnimalTrack", title="End Tag - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -1175,9 +1183,11 @@ async def animal_attrs(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
attrs_form(), attrs_form(),
push_url="/actions/attrs",
title="Update Attributes - AnimalTrack", title="Update Attributes - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -1455,10 +1465,11 @@ async def animal_outcome(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
# Use render_page_post to set HX-Push-Url header for correct browser URL
product_repo = ProductRepository(db) product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active] products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
return render_page( return render_page_post(
request, request,
outcome_form( outcome_form(
filter_str="", filter_str="",
@@ -1468,6 +1479,7 @@ async def animal_outcome(request: Request, session):
resolved_count=0, resolved_count=0,
products=products, products=products,
), ),
push_url="/actions/outcome",
title="Record Outcome - AnimalTrack", title="Record Outcome - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -1678,7 +1690,8 @@ async def animal_status_correct(req: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
req, req,
status_correct_form( status_correct_form(
filter_str="", filter_str="",
@@ -1687,6 +1700,7 @@ async def animal_status_correct(req: Request, session):
ts_utc=int(time.time() * 1000), ts_utc=int(time.time() * 1000),
resolved_count=0, resolved_count=0,
), ),
push_url="/actions/status-correct",
title="Correct Status - AnimalTrack", title="Correct Status - AnimalTrack",
active_nav=None, active_nav=None,
) )

View File

@@ -24,7 +24,7 @@ from animaltrack.repositories.products import ProductRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository from animaltrack.repositories.users import UserRepository
from animaltrack.services.products import ProductService, ValidationError from animaltrack.services.products import ProductService, ValidationError
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page, render_page_post
from animaltrack.web.templates.eggs import eggs_page from animaltrack.web.templates.eggs import eggs_page
# 30 days in milliseconds # 30 days in milliseconds
@@ -365,7 +365,14 @@ async def product_collected(request: Request, session):
# Validate location_id # Validate location_id
if not location_id: if not location_id:
return _render_harvest_error( return _render_harvest_error(
request, db, locations, products, None, "Please select a location" request,
db,
locations,
products,
None,
"Please select a location",
quantity=quantity_str,
notes=notes,
) )
# Validate quantity # Validate quantity
@@ -373,12 +380,26 @@ async def product_collected(request: Request, session):
quantity = int(quantity_str) quantity = int(quantity_str)
except ValueError: except ValueError:
return _render_harvest_error( return _render_harvest_error(
request, db, locations, products, location_id, "Quantity must be a number" request,
db,
locations,
products,
location_id,
"Quantity must be a number",
quantity=quantity_str,
notes=notes,
) )
if quantity < 0: if quantity < 0:
return _render_harvest_error( return _render_harvest_error(
request, db, locations, products, location_id, "Quantity cannot be negative" request,
db,
locations,
products,
location_id,
"Quantity cannot be negative",
quantity=quantity_str,
notes=notes,
) )
# Get timestamp - use provided or current (supports backdating) # Get timestamp - use provided or current (supports backdating)
@@ -389,7 +410,14 @@ async def product_collected(request: Request, session):
if not resolved_ids: if not resolved_ids:
return _render_harvest_error( return _render_harvest_error(
request, db, locations, products, location_id, "No ducks at this location" request,
db,
locations,
products,
location_id,
"No ducks at this location",
quantity=quantity_str,
notes=notes,
) )
# Create product service # Create product service
@@ -422,7 +450,16 @@ async def product_collected(request: Request, session):
route="/actions/product-collected", route="/actions/product-collected",
) )
except ValidationError as e: except ValidationError as e:
return _render_harvest_error(request, db, locations, products, location_id, str(e)) return _render_harvest_error(
request,
db,
locations,
products,
location_id,
str(e),
quantity=quantity_str,
notes=notes,
)
# Save user defaults (only if user exists in database) # Save user defaults (only if user exists in database)
if UserRepository(db).get(actor): if UserRepository(db).get(actor):
@@ -446,7 +483,8 @@ async def product_collected(request: Request, session):
display_data = _get_eggs_display_data(db, locations) display_data = _get_eggs_display_data(db, locations)
# Success: re-render form with location sticking, qty cleared # Success: re-render form with location sticking, qty cleared
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
eggs_page( eggs_page(
locations, locations,
@@ -457,6 +495,7 @@ async def product_collected(request: Request, session):
sell_action=product_sold, sell_action=product_sold,
**display_data, **display_data,
), ),
push_url="/",
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="eggs", active_nav="eggs",
) )
@@ -486,19 +525,48 @@ async def product_sold(request: Request, session):
# Validate product_code # Validate product_code
if not product_code: if not product_code:
return _render_sell_error(request, db, locations, products, None, "Please select a product") return _render_sell_error(
request,
db,
locations,
products,
None,
"Please select a product",
quantity=quantity_str,
total_price_cents=total_price_str,
buyer=buyer,
notes=notes,
)
# Validate quantity # Validate quantity
try: try:
quantity = int(quantity_str) quantity = int(quantity_str)
except ValueError: except ValueError:
return _render_sell_error( return _render_sell_error(
request, db, locations, products, product_code, "Quantity must be a number" request,
db,
locations,
products,
product_code,
"Quantity must be a number",
quantity=quantity_str,
total_price_cents=total_price_str,
buyer=buyer,
notes=notes,
) )
if quantity < 1: if quantity < 1:
return _render_sell_error( return _render_sell_error(
request, db, locations, products, product_code, "Quantity must be at least 1" request,
db,
locations,
products,
product_code,
"Quantity must be at least 1",
quantity=quantity_str,
total_price_cents=total_price_str,
buyer=buyer,
notes=notes,
) )
# Validate total_price_cents # Validate total_price_cents
@@ -506,12 +574,30 @@ async def product_sold(request: Request, session):
total_price_cents = int(total_price_str) total_price_cents = int(total_price_str)
except ValueError: except ValueError:
return _render_sell_error( return _render_sell_error(
request, db, locations, products, product_code, "Total price must be a number" request,
db,
locations,
products,
product_code,
"Total price must be a number",
quantity=quantity_str,
total_price_cents=total_price_str,
buyer=buyer,
notes=notes,
) )
if total_price_cents < 0: if total_price_cents < 0:
return _render_sell_error( return _render_sell_error(
request, db, locations, products, product_code, "Total price cannot be negative" request,
db,
locations,
products,
product_code,
"Total price cannot be negative",
quantity=quantity_str,
total_price_cents=total_price_str,
buyer=buyer,
notes=notes,
) )
# Get timestamp - use provided or current (supports backdating) # Get timestamp - use provided or current (supports backdating)
@@ -544,7 +630,18 @@ async def product_sold(request: Request, session):
route="/actions/product-sold", route="/actions/product-sold",
) )
except ValidationError as e: except ValidationError as e:
return _render_sell_error(request, db, locations, products, product_code, str(e)) return _render_sell_error(
request,
db,
locations,
products,
product_code,
str(e),
quantity=quantity_str,
total_price_cents=total_price_str,
buyer=buyer,
notes=notes,
)
# Add success toast with link to event # Add success toast with link to event
add_toast( add_toast(
@@ -557,7 +654,8 @@ async def product_sold(request: Request, session):
display_data = _get_eggs_display_data(db, locations) display_data = _get_eggs_display_data(db, locations)
# Success: re-render form with product sticking # Success: re-render form with product sticking
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
eggs_page( eggs_page(
locations, locations,
@@ -568,13 +666,23 @@ async def product_sold(request: Request, session):
sell_action=product_sold, sell_action=product_sold,
**display_data, **display_data,
), ),
push_url="/",
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="eggs", active_nav="eggs",
) )
def _render_harvest_error(request, db, locations, products, selected_location_id, error_message): def _render_harvest_error(
"""Render harvest form with error message. request,
db,
locations,
products,
selected_location_id,
error_message,
quantity: str | None = None,
notes: str | None = None,
):
"""Render harvest form with error message and preserved field values.
Args: Args:
request: The HTTP request. request: The HTTP request.
@@ -583,6 +691,8 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
products: List of sellable products. products: List of sellable products.
selected_location_id: Currently selected location. selected_location_id: Currently selected location.
error_message: Error message to display. error_message: Error message to display.
quantity: Quantity value to preserve.
notes: Notes value to preserve.
Returns: Returns:
HTMLResponse with 422 status. HTMLResponse with 422 status.
@@ -600,6 +710,8 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
harvest_error=error_message, harvest_error=error_message,
harvest_action=product_collected, harvest_action=product_collected,
sell_action=product_sold, sell_action=product_sold,
harvest_quantity=quantity,
harvest_notes=notes,
**display_data, **display_data,
), ),
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
@@ -610,8 +722,19 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
) )
def _render_sell_error(request, db, locations, products, selected_product_code, error_message): def _render_sell_error(
"""Render sell form with error message. request,
db,
locations,
products,
selected_product_code,
error_message,
quantity: str | None = None,
total_price_cents: str | None = None,
buyer: str | None = None,
notes: str | None = None,
):
"""Render sell form with error message and preserved field values.
Args: Args:
request: The HTTP request. request: The HTTP request.
@@ -620,6 +743,10 @@ def _render_sell_error(request, db, locations, products, selected_product_code,
products: List of sellable products. products: List of sellable products.
selected_product_code: Currently selected product code. selected_product_code: Currently selected product code.
error_message: Error message to display. error_message: Error message to display.
quantity: Quantity value to preserve.
total_price_cents: Total price value to preserve.
buyer: Buyer value to preserve.
notes: Notes value to preserve.
Returns: Returns:
HTMLResponse with 422 status. HTMLResponse with 422 status.
@@ -637,6 +764,10 @@ def _render_sell_error(request, db, locations, products, selected_product_code,
sell_error=error_message, sell_error=error_message,
harvest_action=product_collected, harvest_action=product_collected,
sell_action=product_sold, sell_action=product_sold,
sell_quantity=quantity,
sell_total_price_cents=total_price_cents,
sell_buyer=buyer,
sell_notes=notes,
**display_data, **display_data,
), ),
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",

View File

@@ -22,7 +22,7 @@ from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository from animaltrack.repositories.users import UserRepository
from animaltrack.services.feed import FeedService, ValidationError from animaltrack.services.feed import FeedService, ValidationError
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page, render_page_post
from animaltrack.web.templates.feed import feed_page from animaltrack.web.templates.feed import feed_page
# 30 days in milliseconds # 30 days in milliseconds
@@ -498,7 +498,8 @@ async def feed_given(request: Request, session):
display_data = _get_feed_display_data(db, locations, feed_types) display_data = _get_feed_display_data(db, locations, feed_types)
# Success: re-render form with location/type sticking, amount reset # Success: re-render form with location/type sticking, amount reset
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
feed_page( feed_page(
locations, locations,
@@ -512,6 +513,7 @@ async def feed_given(request: Request, session):
purchase_action=feed_purchased, purchase_action=feed_purchased,
**display_data, **display_data,
), ),
push_url="/feed",
title="Feed - AnimalTrack", title="Feed - AnimalTrack",
active_nav="feed", active_nav="feed",
) )
@@ -666,7 +668,8 @@ async def feed_purchased(request: Request, session):
display_data = _get_feed_display_data(db, locations, feed_types) display_data = _get_feed_display_data(db, locations, feed_types)
# Success: re-render form with fields cleared # Success: re-render form with fields cleared
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
feed_page( feed_page(
locations, locations,
@@ -676,6 +679,7 @@ async def feed_purchased(request: Request, session):
purchase_action=feed_purchased, purchase_action=feed_purchased,
**display_data, **display_data,
), ),
push_url="/feed",
title="Feed - AnimalTrack", title="Feed - AnimalTrack",
active_nav="feed", active_nav="feed",
) )

View File

@@ -23,7 +23,7 @@ from animaltrack.repositories.locations import LocationRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page, render_page_post
from animaltrack.web.templates.move import diff_panel, move_form from animaltrack.web.templates.move import diff_panel, move_form
# Milliseconds per day # Milliseconds per day
@@ -396,13 +396,15 @@ async def animal_move(request: Request, session):
display_data = _get_move_display_data(db, locations) display_data = _get_move_display_data(db, locations)
# Success: re-render fresh form (nothing sticks per spec) # Success: re-render fresh form (nothing sticks per spec)
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
move_form( move_form(
locations, locations,
action=animal_move, action=animal_move,
**display_data, **display_data,
), ),
push_url="/move",
title="Move - AnimalTrack", title="Move - AnimalTrack",
active_nav="move", active_nav="move",
) )

View File

@@ -1,7 +1,7 @@
# ABOUTME: Templates package for AnimalTrack web UI. # ABOUTME: Templates package for AnimalTrack web UI.
# ABOUTME: Contains reusable UI components built with FastHTML and MonsterUI. # ABOUTME: Contains reusable UI components built with FastHTML and MonsterUI.
from animaltrack.web.templates.base import page, render_page from animaltrack.web.templates.base import page, render_page, render_page_post
from animaltrack.web.templates.nav import BottomNav from animaltrack.web.templates.nav import BottomNav
__all__ = ["page", "render_page", "BottomNav"] __all__ = ["page", "render_page", "render_page_post", "BottomNav"]

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, Select, Span from fasthtml.common import H2, H3, A, Div, Form, Hidden, Input, Option, P, Script, Select, Span
from monsterui.all import ( from monsterui.all import (
Alert, Alert,
AlertT, AlertT,
@@ -12,7 +12,6 @@ from monsterui.all import (
ButtonT, ButtonT,
FormLabel, FormLabel,
LabelInput, LabelInput,
LabelSelect,
LabelTextArea, LabelTextArea,
) )
from ulid import ULID from ulid import ULID
@@ -21,6 +20,103 @@ from animaltrack.models.animals import Animal
from animaltrack.models.reference import Location, Species from animaltrack.models.reference import Location, Species
from animaltrack.selection.validation import SelectionDiff from animaltrack.selection.validation import SelectionDiff
# =============================================================================
# Selection Diff Confirmation Panel
# =============================================================================
def diff_confirmation_panel(
diff: SelectionDiff,
filter_str: str,
resolved_ids: list[str],
roster_hash: str,
ts_utc: int,
action: Callable[..., Any] | str,
action_hidden_fields: list[tuple[str, str]],
cancel_url: str,
confirm_button_text: str,
question_text: str,
confirm_button_cls: str = ButtonT.primary,
) -> Div:
"""Create a confirmation panel for selection mismatch scenarios.
This is a reusable component for all action forms that use optimistic locking.
When the client's selection differs from the server's current state, this panel
shows what changed and asks for confirmation before proceeding.
Args:
diff: SelectionDiff with added/removed counts.
filter_str: Original filter string.
resolved_ids: Server's resolved IDs (current).
roster_hash: Server's roster hash (current).
ts_utc: Timestamp for resolution.
action: Route function or URL for confirmation submit.
action_hidden_fields: List of (name, value) tuples for action-specific fields.
cancel_url: URL for the cancel button.
confirm_button_text: Text for the confirm button.
question_text: Question shown in the alert (e.g. "Would you like to...").
confirm_button_cls: Button style class (default: ButtonT.primary).
Returns:
Div containing the diff panel with confirm button.
"""
# Build description of changes
changes = []
if diff.removed:
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
if diff.added:
changes.append(f"{len(diff.added)} animals were added")
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
# Build action-specific hidden fields
action_fields = [Hidden(name=name, value=value) for name, value in action_hidden_fields]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
*action_fields,
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
A(
"Cancel",
href=cancel_url,
cls=ButtonT.default,
),
Button(
confirm_button_text,
type="submit",
cls=confirm_button_cls,
hx_disabled_elt="this",
),
cls="flex gap-3 mt-4",
),
action=action,
method="post",
)
return Div(
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(question_text, cls="text-sm"),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
)
# ============================================================================= # =============================================================================
# Event Datetime Picker Component # Event Datetime Picker Component
# ============================================================================= # =============================================================================
@@ -45,43 +141,14 @@ def event_datetime_field(
Returns: Returns:
Div containing the datetime picker with toggle functionality. Div containing the datetime picker with toggle functionality.
""" """
picker_id = f"{field_id}_picker"
input_id = f"{field_id}_input"
ts_utc_id = f"{field_id}_ts_utc"
# If initial value is set, start with picker expanded # If initial value is set, start with picker expanded
has_initial = bool(initial_value) has_initial = bool(initial_value)
picker_style = "display: block;" if has_initial else "display: none;" picker_style = "display: block;" if has_initial else "display: none;"
toggle_text = "Use current time" if has_initial else "Set custom date" toggle_text = "Use current time" if has_initial else "Set custom date"
# Inline JavaScript for toggle click handler
toggle_onclick = f"""
var picker = document.getElementById('{picker_id}');
var input = document.getElementById('{input_id}');
var tsField = document.getElementById('{ts_utc_id}');
if (picker.style.display === 'none') {{
picker.style.display = 'block';
this.textContent = 'Use current time';
}} else {{
picker.style.display = 'none';
this.textContent = 'Set custom date';
input.value = '';
if (tsField) tsField.value = '0';
}}
"""
# Inline JavaScript for input change handler
input_onchange = f"""
var tsField = document.getElementById('{ts_utc_id}');
if (tsField && this.value) {{
var date = new Date(this.value);
tsField.value = date.getTime().toString();
}} else if (tsField) {{
tsField.value = '0';
}}
"""
return Div( return Div(
# Load static JS for datetime picker functionality
Script(src="/static/v1/datetime-picker.js"),
FormLabel("Event Time"), FormLabel("Event Time"),
Div( Div(
P( P(
@@ -90,29 +157,30 @@ def event_datetime_field(
Span( Span(
toggle_text, toggle_text,
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline", cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
hx_on_click=toggle_onclick, data_datetime_toggle=field_id,
hx_on_click=f"toggleDatetimePicker('{field_id}')",
), ),
cls="text-sm", cls="text-sm",
), ),
Div( Div(
Input( Input(
id=input_id,
name=f"{field_id}_value", name=f"{field_id}_value",
type="datetime-local", type="datetime-local",
value=initial_value, value=initial_value,
cls="uk-input w-full mt-2", cls="uk-input w-full mt-2",
hx_on_change=input_onchange, data_datetime_input=field_id,
hx_on_change=f"updateDatetimeTs('{field_id}')",
), ),
P( P(
"Select date/time for this event (leave empty for current time)", "Select date/time for this event (leave empty for current time)",
cls="text-xs text-stone-500 mt-1", cls="text-xs text-stone-500 mt-1",
), ),
id=picker_id, data_datetime_picker=field_id,
style=picker_style, style=picker_style,
), ),
cls="mt-1", cls="mt-1",
), ),
Hidden(id=ts_utc_id, name="ts_utc", value=initial_ts), Hidden(name="ts_utc", value=initial_ts, data_datetime_ts=field_id),
cls="space-y-1", cls="space-y-1",
) )
@@ -215,19 +283,17 @@ def cohort_form(
H2("Create Animal Cohort", cls="text-xl font-bold mb-4"), H2("Create Animal Cohort", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Species dropdown # Species dropdown - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*species_options, FormLabel("Species", _for="species"),
label="Species", Select(*species_options, name="species", id="species", cls="uk-select"),
id="species", cls="space-y-2",
name="species",
), ),
# Location dropdown # Location dropdown - using raw Select due to MonsterUI LabelSelect value bug
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",
), ),
# Count input # Count input
LabelInput( LabelInput(
@@ -239,26 +305,23 @@ def cohort_form(
value=count_value, value=count_value,
placeholder="Number of animals", placeholder="Number of animals",
), ),
# Life stage dropdown # Life stage dropdown - using raw Select due to MonsterUI LabelSelect value bug
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",
), ),
# Sex dropdown # Sex dropdown - using raw Select due to MonsterUI LabelSelect value bug
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",
), ),
# Origin dropdown # Origin dropdown - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*origin_options, FormLabel("Origin", _for="origin"),
label="Origin", Select(*origin_options, name="origin", id="origin", cls="uk-select"),
id="origin", cls="space-y-2",
name="origin",
), ),
# Optional notes # Optional notes
LabelTextArea( LabelTextArea(
@@ -272,7 +335,7 @@ def cohort_form(
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Create Cohort", type="submit", cls=ButtonT.primary), Button("Create Cohort", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
# 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",
@@ -350,19 +413,17 @@ def hatch_form(
H2("Record Hatch", cls="text-xl font-bold mb-4"), H2("Record Hatch", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Species dropdown # Species dropdown - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*species_options, FormLabel("Species", _for="species"),
label="Species", Select(*species_options, name="species", id="species", cls="uk-select"),
id="species", cls="space-y-2",
name="species",
), ),
# Hatch location dropdown # Hatch location dropdown - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*location_options, FormLabel("Hatch Location", _for="location_id"),
label="Hatch Location", Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
id="location_id", cls="space-y-2",
name="location_id",
), ),
# Hatched count input # Hatched count input
LabelInput( LabelInput(
@@ -374,13 +435,17 @@ def hatch_form(
value=hatched_live_value, value=hatched_live_value,
placeholder="Number hatched", placeholder="Number hatched",
), ),
# Brood location dropdown (optional) # Brood location dropdown (optional) - using raw Select due to MonsterUI LabelSelect value bug
Div( Div(
LabelSelect( Div(
*brood_location_options, FormLabel("Brood Location (optional)", _for="assigned_brood_location_id"),
label="Brood Location (optional)", Select(
id="assigned_brood_location_id", *brood_location_options,
name="assigned_brood_location_id", name="assigned_brood_location_id",
id="assigned_brood_location_id",
cls="uk-select",
),
cls="space-y-2",
), ),
P( P(
"If different from hatch location, hatchlings will be placed here", "If different from hatch location, hatchlings will be placed here",
@@ -399,7 +464,7 @@ def hatch_form(
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Record Hatch", type="submit", cls=ButtonT.primary), Button("Record Hatch", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
# 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",
@@ -526,6 +591,7 @@ def promote_form(
"Save Changes" if is_rename else "Promote to Identified", "Save Changes" if is_rename else "Promote to Identified",
type="submit", type="submit",
cls=ButtonT.primary, cls=ButtonT.primary,
hx_disabled_elt="this",
), ),
# Form submission via standard action/method (hx-boost handles AJAX) # Form submission via standard action/method (hx-boost handles AJAX)
action=action, action=action,
@@ -652,7 +718,7 @@ def tag_add_form(
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Add Tag", type="submit", cls=ButtonT.primary), Button("Add Tag", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
# 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",
@@ -683,60 +749,17 @@ def tag_add_diff_panel(
Returns: Returns:
Div containing the diff panel with confirm button. Div containing the diff panel with confirm button.
""" """
# Build description of changes return diff_confirmation_panel(
changes = [] diff=diff,
if diff.removed: filter_str=filter_str,
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page") resolved_ids=resolved_ids,
if diff.added: roster_hash=roster_hash,
changes.append(f"{len(diff.added)} animals were added") ts_utc=ts_utc,
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="tag", value=tag),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/tag-add'",
),
Button(
f"Confirm Tag ({diff.server_count} animals)",
type="submit",
cls=ButtonT.primary,
),
cls="flex gap-3 mt-4",
),
action=action, action=action,
method="post", action_hidden_fields=[("tag", tag)],
) cancel_url="/actions/tag-add",
confirm_button_text=f"Confirm Tag ({diff.server_count} animals)",
return Div( question_text=f"Would you like to proceed with tagging {diff.server_count} animals as '{tag}'?",
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(
f"Would you like to proceed with tagging {diff.server_count} animals as '{tag}'?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
) )
@@ -845,12 +868,11 @@ def tag_end_form(
), ),
# Selection container - updated via HTMX when filter changes # Selection container - updated via HTMX when filter changes
selection_container, selection_container,
# Tag dropdown # Tag dropdown - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*tag_options, FormLabel("Tag to End", _for="tag"),
label="Tag to End", Select(*tag_options, name="tag", id="tag", cls="uk-select"),
id="tag", cls="space-y-2",
name="tag",
) )
if active_tags if active_tags
else Div( else Div(
@@ -872,7 +894,13 @@ def tag_end_form(
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags), Button(
"End Tag",
type="submit",
cls=ButtonT.primary,
disabled=not active_tags,
hx_disabled_elt="this",
),
# 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",
@@ -903,60 +931,17 @@ def tag_end_diff_panel(
Returns: Returns:
Div containing the diff panel with confirm button. Div containing the diff panel with confirm button.
""" """
# Build description of changes return diff_confirmation_panel(
changes = [] diff=diff,
if diff.removed: filter_str=filter_str,
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page") resolved_ids=resolved_ids,
if diff.added: roster_hash=roster_hash,
changes.append(f"{len(diff.added)} animals were added") ts_utc=ts_utc,
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="tag", value=tag),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/tag-end'",
),
Button(
f"Confirm End Tag ({diff.server_count} animals)",
type="submit",
cls=ButtonT.primary,
),
cls="flex gap-3 mt-4",
),
action=action, action=action,
method="post", action_hidden_fields=[("tag", tag)],
) cancel_url="/actions/tag-end",
confirm_button_text=f"Confirm End Tag ({diff.server_count} animals)",
return Div( question_text=f"Would you like to proceed with ending tag '{tag}' on {diff.server_count} animals?",
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(
f"Would you like to proceed with ending tag '{tag}' on {diff.server_count} animals?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
) )
@@ -1115,7 +1100,7 @@ def attrs_form(
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Update Attributes", type="submit", cls=ButtonT.primary), Button("Update Attributes", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
# 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",
@@ -1150,62 +1135,21 @@ def attrs_diff_panel(
Returns: Returns:
Div containing the diff panel with confirm button. Div containing the diff panel with confirm button.
""" """
# Build description of changes return diff_confirmation_panel(
changes = [] diff=diff,
if diff.removed: filter_str=filter_str,
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page") resolved_ids=resolved_ids,
if diff.added: roster_hash=roster_hash,
changes.append(f"{len(diff.added)} animals were added") ts_utc=ts_utc,
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="sex", value=sex or ""),
Hidden(name="life_stage", value=life_stage or ""),
Hidden(name="repro_status", value=repro_status or ""),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/attrs'",
),
Button(
f"Confirm Update ({diff.server_count} animals)",
type="submit",
cls=ButtonT.primary,
),
cls="flex gap-3 mt-4",
),
action=action, action=action,
method="post", action_hidden_fields=[
) ("sex", sex or ""),
("life_stage", life_stage or ""),
return Div( ("repro_status", repro_status or ""),
Alert( ],
Div( cancel_url="/actions/attrs",
P("Selection Changed", cls="font-bold text-lg mb-2"), confirm_button_text=f"Confirm Update ({diff.server_count} animals)",
P(changes_text, cls="mb-2"), question_text=f"Would you like to proceed with updating {diff.server_count} animals?",
P(
f"Would you like to proceed with updating {diff.server_count} animals?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
) )
@@ -1318,20 +1262,22 @@ def outcome_form(
yield_section = Div( yield_section = Div(
H3("Yield Items", cls="text-lg font-semibold mt-4 mb-2"), H3("Yield Items", cls="text-lg font-semibold mt-4 mb-2"),
P("Optional: record products collected from harvest", cls="text-sm text-stone-500 mb-3"), P("Optional: record products collected from harvest", cls="text-sm text-stone-500 mb-3"),
# Using raw Select due to MonsterUI LabelSelect value bug
Div( Div(
LabelSelect( Div(
*product_options, FormLabel("Product", _for="yield_product_code"),
label="Product", Select(
id="yield_product_code", *product_options,
name="yield_product_code", name="yield_product_code",
cls="flex-1", id="yield_product_code",
cls="uk-select",
),
cls="space-y-2 flex-1",
), ),
LabelSelect( Div(
*unit_options, FormLabel("Unit", _for="yield_unit"),
label="Unit", Select(*unit_options, name="yield_unit", id="yield_unit", cls="uk-select"),
id="yield_unit", cls="space-y-2 w-32",
name="yield_unit",
cls="w-32",
), ),
cls="flex gap-3", cls="flex gap-3",
), ),
@@ -1377,13 +1323,11 @@ def outcome_form(
), ),
# Selection container - updated via HTMX when filter changes # Selection container - updated via HTMX when filter changes
selection_container, selection_container,
# Outcome selection # Outcome selection - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*outcome_options, FormLabel("Outcome", _for="outcome"),
label="Outcome", Select(*outcome_options, name="outcome", id="outcome", cls="uk-select", required=True),
id="outcome", cls="space-y-2",
name="outcome",
required=True,
), ),
# Reason field # Reason field
LabelInput( LabelInput(
@@ -1410,7 +1354,7 @@ def outcome_form(
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Record Outcome", type="submit", cls=ButtonT.destructive), Button("Record Outcome", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"),
# 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",
@@ -1451,65 +1395,25 @@ def outcome_diff_panel(
Returns: Returns:
Div containing the diff panel with confirm button. Div containing the diff panel with confirm button.
""" """
# Build description of changes return diff_confirmation_panel(
changes = [] diff=diff,
if diff.removed: filter_str=filter_str,
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page") resolved_ids=resolved_ids,
if diff.added: roster_hash=roster_hash,
changes.append(f"{len(diff.added)} animals were added") ts_utc=ts_utc,
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="outcome", value=outcome),
Hidden(name="reason", value=reason or ""),
Hidden(name="yield_product_code", value=yield_product_code or ""),
Hidden(name="yield_unit", value=yield_unit or ""),
Hidden(name="yield_quantity", value=str(yield_quantity) if yield_quantity else ""),
Hidden(name="yield_weight_kg", value=str(yield_weight_kg) if yield_weight_kg else ""),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/outcome'",
),
Button(
f"Confirm Outcome ({diff.server_count} animals)",
type="submit",
cls=ButtonT.destructive,
),
cls="flex gap-3 mt-4",
),
action=action, action=action,
method="post", action_hidden_fields=[
) ("outcome", outcome),
("reason", reason or ""),
return Div( ("yield_product_code", yield_product_code or ""),
Alert( ("yield_unit", yield_unit or ""),
Div( ("yield_quantity", str(yield_quantity) if yield_quantity else ""),
P("Selection Changed", cls="font-bold text-lg mb-2"), ("yield_weight_kg", str(yield_weight_kg) if yield_weight_kg else ""),
P(changes_text, cls="mb-2"), ],
P( cancel_url="/actions/outcome",
f"Would you like to proceed with recording {outcome} for {diff.server_count} animals?", confirm_button_text=f"Confirm Outcome ({diff.server_count} animals)",
cls="text-sm", question_text=f"Would you like to proceed with recording {outcome} for {diff.server_count} animals?",
), confirm_button_cls=ButtonT.destructive,
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
) )
@@ -1599,13 +1503,13 @@ def status_correct_form(
value=filter_str, value=filter_str,
placeholder="e.g., species:duck location:Coop1", placeholder="e.g., species:duck location:Coop1",
), ),
# New status selection # New status selection - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*status_options, FormLabel("New Status", _for="new_status"),
label="New Status", Select(
id="new_status", *status_options, name="new_status", id="new_status", cls="uk-select", required=True
name="new_status", ),
required=True, cls="space-y-2",
), ),
# Reason field (required for admin actions) # Reason field (required for admin actions)
LabelInput( LabelInput(
@@ -1631,7 +1535,7 @@ def status_correct_form(
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Correct Status", type="submit", cls=ButtonT.destructive), Button("Correct Status", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"),
# 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",
@@ -1664,59 +1568,19 @@ def status_correct_diff_panel(
Returns: Returns:
Div containing the diff panel with confirm button. Div containing the diff panel with confirm button.
""" """
# Build description of changes return diff_confirmation_panel(
changes = [] diff=diff,
if diff.removed: filter_str=filter_str,
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page") resolved_ids=resolved_ids,
if diff.added: roster_hash=roster_hash,
changes.append(f"{len(diff.added)} animals were added") ts_utc=ts_utc,
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="new_status", value=new_status),
Hidden(name="reason", value=reason),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/status-correct'",
),
Button(
f"Confirm Correction ({diff.server_count} animals)",
type="submit",
cls=ButtonT.destructive,
),
cls="flex gap-3 mt-4",
),
action=action, action=action,
method="post", action_hidden_fields=[
) ("new_status", new_status),
("reason", reason),
return Div( ],
Alert( cancel_url="/actions/status-correct",
Div( confirm_button_text=f"Confirm Correction ({diff.server_count} animals)",
P("Selection Changed", cls="font-bold text-lg mb-2"), question_text=f"Would you like to proceed with correcting status to {new_status} for {diff.server_count} animals?",
P(changes_text, cls="mb-2"), confirm_button_cls=ButtonT.destructive,
P(
f"Would you like to proceed with correcting status to {new_status} for {diff.server_count} animals?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
) )

View File

@@ -1,11 +1,12 @@
# ABOUTME: Base HTML template for AnimalTrack pages. # ABOUTME: Base HTML template for AnimalTrack pages.
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav. # ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
from fasthtml.common import Container, Div, Script, Style, Title from fasthtml.common import Container, Div, HttpHeader, Script, Style, Title
from starlette.requests import Request from starlette.requests import Request
from animaltrack.models.reference import UserRole from animaltrack.models.reference import UserRole
from animaltrack.web.templates.nav import BottomNav, BottomNavStyles from animaltrack.web.templates.nav import BottomNav, BottomNavStyles
from animaltrack.web.templates.shared_scripts import slide_over_script
from animaltrack.web.templates.sidebar import ( from animaltrack.web.templates.sidebar import (
MenuDrawer, MenuDrawer,
Sidebar, Sidebar,
@@ -79,29 +80,13 @@ def EventSlideOverStyles(): # noqa: N802
def EventSlideOverScript(): # noqa: N802 def EventSlideOverScript(): # noqa: N802
"""JavaScript for event slide-over panel open/close behavior.""" """JavaScript for event slide-over panel open/close behavior."""
return Script(""" return slide_over_script(
function openEventPanel() { panel_id="event-slide-over",
document.getElementById('event-slide-over').classList.add('open'); backdrop_id="event-backdrop",
document.getElementById('event-backdrop').classList.add('open'); open_fn_name="openEventPanel",
document.body.style.overflow = 'hidden'; close_fn_name="closeEventPanel",
// Focus the panel for keyboard events htmx_auto_open_targets=["event-slide-over", "event-panel-content"],
document.getElementById('event-slide-over').focus(); )
}
function closeEventPanel() {
document.getElementById('event-slide-over').classList.remove('open');
document.getElementById('event-backdrop').classList.remove('open');
document.body.style.overflow = '';
}
// HTMX event: after loading event content, open the panel
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'event-slide-over' ||
evt.detail.target.id === 'event-panel-content') {
openEventPanel();
}
});
""")
def CsrfHeaderScript(): # noqa: N802 def CsrfHeaderScript(): # noqa: N802
@@ -157,6 +142,8 @@ def EventSlideOver(): # noqa: N802
"shadow-2xl border-l border-stone-700 overflow-hidden", "shadow-2xl border-l border-stone-700 overflow-hidden",
tabindex="-1", tabindex="-1",
hx_on_keydown="if(event.key==='Escape') closeEventPanel()", hx_on_keydown="if(event.key==='Escape') closeEventPanel()",
role="dialog",
aria_label="Event details",
), ),
) )
@@ -215,7 +202,7 @@ def page(
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100", cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
), ),
# Toast container with hx-preserve to survive body swaps for OOB toast injection # Toast container with hx-preserve to survive body swaps for OOB toast injection
Div(id="fh-toast-container", hx_preserve=True), Div(id="fh-toast-container", hx_preserve=True, aria_live="polite"),
# Mobile bottom nav # Mobile bottom nav
BottomNav(active_id=active_nav), BottomNav(active_id=active_nav),
) )
@@ -243,3 +230,26 @@ def render_page(request: Request, content, **page_kwargs):
user_role=auth.role if auth else None, user_role=auth.role if auth else None,
**page_kwargs, **page_kwargs,
) )
def render_page_post(request: Request, content, push_url: str, **page_kwargs):
"""Wrapper for POST responses that sets HX-Push-Url header.
When HTMX boosted forms submit via POST, the browser URL may not be updated
correctly. This wrapper returns the rendered page with an HX-Push-Url header
to ensure the browser history shows the correct URL.
This fixes the issue where window.location.reload() after form submission
would reload the wrong URL (the action URL instead of the display URL).
Args:
request: The Starlette request object.
content: Page content (FT components).
push_url: The URL to push to browser history (e.g., '/feed', '/move').
**page_kwargs: Additional arguments passed to page() (title, active_nav).
Returns:
Tuple of (FT components, HttpHeader) that FastHTML processes together.
"""
page_content = render_page(request, content, **page_kwargs)
return (*page_content, HttpHeader("HX-Push-Url", push_url))

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,
) )
@@ -37,6 +37,13 @@ def eggs_page(
cost_per_egg: float | None = None, cost_per_egg: float | None = None,
sales_stats: dict | None = None, sales_stats: dict | None = None,
location_names: dict[str, str] | None = None, location_names: dict[str, str] | None = None,
# Field value preservation on errors
harvest_quantity: str | None = None,
harvest_notes: str | None = None,
sell_quantity: str | None = None,
sell_total_price_cents: str | None = None,
sell_buyer: str | None = None,
sell_notes: str | None = None,
): ):
"""Create the Eggs page with tabbed forms. """Create the Eggs page with tabbed forms.
@@ -56,6 +63,12 @@ def eggs_page(
cost_per_egg: 30-day average cost per egg in EUR. cost_per_egg: 30-day average cost per egg in EUR.
sales_stats: Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents'. sales_stats: Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents'.
location_names: Dict mapping location_id to location name for display. location_names: Dict mapping location_id to location name for display.
harvest_quantity: Preserved quantity value on error.
harvest_notes: Preserved notes value on error.
sell_quantity: Preserved quantity value on error.
sell_total_price_cents: Preserved total price value on error.
sell_buyer: Preserved buyer value on error.
sell_notes: Preserved notes value on error.
Returns: Returns:
Page content with tabbed forms. Page content with tabbed forms.
@@ -85,6 +98,8 @@ def eggs_page(
eggs_per_day=eggs_per_day, eggs_per_day=eggs_per_day,
cost_per_egg=cost_per_egg, cost_per_egg=cost_per_egg,
location_names=location_names, location_names=location_names,
default_quantity=harvest_quantity,
default_notes=harvest_notes,
), ),
cls="uk-active" if harvest_active else None, cls="uk-active" if harvest_active else None,
), ),
@@ -96,6 +111,10 @@ def eggs_page(
action=sell_action, action=sell_action,
recent_events=sell_events, recent_events=sell_events,
sales_stats=sales_stats, sales_stats=sales_stats,
default_quantity=sell_quantity,
default_total_price_cents=sell_total_price_cents,
default_buyer=sell_buyer,
default_notes=sell_notes,
), ),
cls=None if harvest_active else "uk-active", cls=None if harvest_active else "uk-active",
), ),
@@ -113,6 +132,8 @@ def harvest_form(
eggs_per_day: float | None = None, eggs_per_day: float | None = None,
cost_per_egg: float | None = None, cost_per_egg: float | None = None,
location_names: dict[str, str] | None = None, location_names: dict[str, str] | None = None,
default_quantity: str | None = None,
default_notes: str | None = None,
) -> Div: ) -> Div:
"""Create the Harvest form for egg collection. """Create the Harvest form for egg collection.
@@ -125,6 +146,8 @@ def harvest_form(
eggs_per_day: 30-day average eggs per day. eggs_per_day: 30-day average eggs per day.
cost_per_egg: 30-day average cost per egg in EUR. cost_per_egg: 30-day average cost per egg in EUR.
location_names: Dict mapping location_id to location name for display. location_names: Dict mapping location_id to location name for display.
default_quantity: Preserved quantity value on error.
default_notes: Preserved notes value on error.
Returns: Returns:
Div containing form and recent events section. Div containing form and recent events section.
@@ -177,12 +200,11 @@ def harvest_form(
H2("Harvest Eggs", cls="text-xl font-bold mb-4"), H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Location dropdown # Location dropdown - using raw Select due to MonsterUI LabelSelect value bug
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",
), ),
# Quantity input (integer only, 0 allowed for "checked but found none") # Quantity input (integer only, 0 allowed for "checked but found none")
LabelInput( LabelInput(
@@ -194,6 +216,7 @@ def harvest_form(
step="1", step="1",
placeholder="Number of eggs", placeholder="Number of eggs",
required=True, required=True,
value=default_quantity or "",
), ),
# Optional notes # Optional notes
LabelTextArea( LabelTextArea(
@@ -201,13 +224,14 @@ def harvest_form(
id="notes", id="notes",
name="notes", name="notes",
placeholder="Optional notes", placeholder="Optional notes",
value=default_notes or "",
), ),
# Event datetime picker (for backdating) # Event datetime picker (for backdating)
event_datetime_field("harvest_datetime"), event_datetime_field("harvest_datetime"),
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Record Harvest", type="submit", cls=ButtonT.primary), Button("Record Harvest", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
# 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",
@@ -232,6 +256,10 @@ def sell_form(
action: Callable[..., Any] | str = "/actions/product-sold", action: Callable[..., Any] | str = "/actions/product-sold",
recent_events: list[tuple[Event, bool]] | None = None, recent_events: list[tuple[Event, bool]] | None = None,
sales_stats: dict | None = None, sales_stats: dict | None = None,
default_quantity: str | None = None,
default_total_price_cents: str | None = None,
default_buyer: str | None = None,
default_notes: str | None = None,
) -> Div: ) -> Div:
"""Create the Sell form for recording sales. """Create the Sell form for recording sales.
@@ -242,6 +270,10 @@ def sell_form(
action: Route function or URL string for form submission. action: Route function or URL string for form submission.
recent_events: Recent (Event, is_deleted) tuples, most recent first. recent_events: Recent (Event, is_deleted) tuples, most recent first.
sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales. sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales.
default_quantity: Preserved quantity value on error.
default_total_price_cents: Preserved total price value on error.
default_buyer: Preserved buyer value on error.
default_notes: Preserved notes value on error.
Returns: Returns:
Div containing form and recent events section. Div containing form and recent events section.
@@ -300,12 +332,11 @@ def sell_form(
H2("Sell Products", cls="text-xl font-bold mb-4"), H2("Sell Products", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Product dropdown # Product dropdown - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*product_options, FormLabel("Product", _for="product_code"),
label="Product", Select(*product_options, name="product_code", id="product_code", cls="uk-select"),
id="product_code", cls="space-y-2",
name="product_code",
), ),
# Quantity input (integer only, min=1) # Quantity input (integer only, min=1)
LabelInput( LabelInput(
@@ -317,6 +348,7 @@ def sell_form(
step="1", step="1",
placeholder="Number of items sold", placeholder="Number of items sold",
required=True, required=True,
value=default_quantity or "",
), ),
# Total price in cents # Total price in cents
LabelInput( LabelInput(
@@ -328,6 +360,7 @@ def sell_form(
step="1", step="1",
placeholder="Total price in cents", placeholder="Total price in cents",
required=True, required=True,
value=default_total_price_cents or "",
), ),
# Optional buyer # Optional buyer
LabelInput( LabelInput(
@@ -336,6 +369,7 @@ def sell_form(
name="buyer", name="buyer",
type="text", type="text",
placeholder="Optional buyer name", placeholder="Optional buyer name",
value=default_buyer or "",
), ),
# Optional notes # Optional notes
LabelTextArea( LabelTextArea(
@@ -343,13 +377,14 @@ def sell_form(
id="sell_notes", id="sell_notes",
name="notes", name="notes",
placeholder="Optional notes", placeholder="Optional notes",
value=default_notes or "",
), ),
# Event datetime picker (for backdating) # Event datetime picker (for backdating)
event_datetime_field("sell_datetime"), event_datetime_field("sell_datetime"),
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Record Sale", type="submit", cls=ButtonT.primary), Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
# 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",

View File

@@ -260,7 +260,7 @@ def give_feed_form(
# Hidden nonce # Hidden nonce
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Record Feed Given", type="submit", cls=ButtonT.primary), Button("Record Feed Given", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
action=action, action=action,
method="post", method="post",
cls="space-y-4", cls="space-y-4",
@@ -404,7 +404,7 @@ def purchase_feed_form(
# Hidden nonce # Hidden nonce
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Record Purchase", type="submit", cls=ButtonT.primary), Button("Record Purchase", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
action=action, action=action,
method="post", method="post",
cls="space-y-4", cls="space-y-4",

View File

@@ -47,7 +47,7 @@ def location_list(
placeholder="Enter location name", placeholder="Enter location name",
), ),
Hidden(name="nonce", value=str(uuid4())), Hidden(name="nonce", value=str(uuid4())),
Button("Create Location", type="submit", cls=ButtonT.primary), Button("Create Location", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
hx_post="/actions/location-created", hx_post="/actions/location-created",
hx_target="#location-list", hx_target="#location-list",
hx_swap="outerHTML", hx_swap="outerHTML",
@@ -160,7 +160,7 @@ def rename_form(
Hidden(name="nonce", value=str(uuid4())), Hidden(name="nonce", value=str(uuid4())),
DivFullySpaced( DivFullySpaced(
Button("Cancel", type="button", cls=ButtonT.ghost, hx_get="/locations"), Button("Cancel", type="button", cls=ButtonT.ghost, hx_get="/locations"),
Button("Rename", type="submit", cls=ButtonT.primary), Button("Rename", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
), ),
hx_post="/actions/location-renamed", hx_post="/actions/location-renamed",
hx_target="#location-list", hx_target="#location-list",

View File

@@ -4,8 +4,8 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from fasthtml.common import H2, Div, Form, Hidden, Option, P, Span from fasthtml.common import H2, A, Div, Form, Hidden, Option, P, Select, Span
from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect, LabelTextArea from monsterui.all import Alert, AlertT, Button, ButtonT, FormLabel, LabelInput, LabelTextArea
from ulid import ULID from ulid import ULID
from animaltrack.models.events import Event from animaltrack.models.events import Event
@@ -151,12 +151,11 @@ def move_form(
), ),
# Selection container - updated via HTMX when filter changes # Selection container - updated via HTMX when filter changes
selection_container, selection_container,
# Destination dropdown # Destination dropdown - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*location_options, FormLabel("Destination", _for="to_location_id"),
label="Destination", Select(*location_options, name="to_location_id", id="to_location_id", cls="uk-select"),
id="to_location_id", cls="space-y-2",
name="to_location_id",
), ),
# Optional notes # Optional notes
LabelTextArea( LabelTextArea(
@@ -175,7 +174,7 @@ def move_form(
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Move Animals", type="submit", cls=ButtonT.primary), Button("Move Animals", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
# 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",
@@ -254,16 +253,16 @@ def diff_panel(
Hidden(name="confirmed", value="true"), Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
Div( Div(
Button( A(
"Cancel", "Cancel",
type="button", href="/move",
cls=ButtonT.default, cls=ButtonT.default,
onclick="window.location.href='/move'",
), ),
Button( Button(
f"Confirm Move ({diff.server_count} animals)", f"Confirm Move ({diff.server_count} animals)",
type="submit", type="submit",
cls=ButtonT.primary, cls=ButtonT.primary,
hx_disabled_elt="this",
), ),
cls="flex gap-3 mt-4", cls="flex gap-3 mt-4",
), ),

View File

@@ -102,6 +102,7 @@ def BottomNav(active_id: str = "eggs"): # noqa: N802
onclick="openMenuDrawer()", onclick="openMenuDrawer()",
cls=wrapper_cls, cls=wrapper_cls,
type="button", type="button",
aria_label="Open navigation menu",
) )
# Regular nav items are links # Regular nav items are links

View File

@@ -4,8 +4,8 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from fasthtml.common import H2, Form, Hidden, Option from fasthtml.common import H2, Div, Form, Hidden, Option, P, Select
from monsterui.all import Button, ButtonT, LabelInput, LabelSelect, LabelTextArea from monsterui.all import Button, ButtonT, FormLabel, LabelInput, LabelTextArea
from ulid import ULID from ulid import ULID
from animaltrack.models.reference import Product from animaltrack.models.reference import Product
@@ -47,8 +47,6 @@ def product_sold_form(
# Error display component # Error display component
error_component = None error_component = None
if error: if error:
from fasthtml.common import Div, P
error_component = Div( error_component = Div(
P(error, cls="text-red-500 text-sm"), P(error, cls="text-red-500 text-sm"),
cls="mb-4", cls="mb-4",
@@ -58,12 +56,11 @@ def product_sold_form(
H2("Record Sale", cls="text-xl font-bold mb-4"), H2("Record Sale", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Product dropdown # Product dropdown - using raw Select due to MonsterUI LabelSelect value bug
LabelSelect( Div(
*product_options, FormLabel("Product", _for="product_code"),
label="Product", Select(*product_options, name="product_code", id="product_code", cls="uk-select"),
id="product_code", cls="space-y-2",
name="product_code",
), ),
# Quantity input (integer only, min=1) # Quantity input (integer only, min=1)
LabelInput( LabelInput(
@@ -105,7 +102,7 @@ def product_sold_form(
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
Button("Record Sale", type="submit", cls=ButtonT.primary), Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
# 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",

View File

@@ -107,7 +107,12 @@ def registry_header(filter_str: str, total_count: int) -> Div:
), ),
# Buttons container # Buttons container
Div( Div(
Button("Apply", type="submit", cls=f"{ButtonT.primary} px-4"), Button(
"Apply",
type="submit",
cls=f"{ButtonT.primary} px-4",
hx_disabled_elt="this",
),
# Clear button (only shown if filter is active) # Clear button (only shown if filter is active)
A( A(
"Clear", "Clear",

View File

@@ -0,0 +1,58 @@
# ABOUTME: Shared JavaScript script generators for AnimalTrack templates.
# ABOUTME: Provides reusable script components to reduce code duplication.
from fasthtml.common import Script
def slide_over_script(
panel_id: str,
backdrop_id: str,
open_fn_name: str,
close_fn_name: str,
htmx_auto_open_targets: list[str] | None = None,
) -> Script:
"""Generate JavaScript for slide-over panel open/close behavior.
Creates global functions for opening and closing a slide-over panel with
backdrop. Optionally auto-opens when HTMX swaps content into specified targets.
Args:
panel_id: DOM ID of the slide-over panel element.
backdrop_id: DOM ID of the backdrop overlay element.
open_fn_name: Name of the global function to open the panel.
close_fn_name: Name of the global function to close the panel.
htmx_auto_open_targets: List of target element IDs that trigger auto-open
when HTMX swaps content into them.
Returns:
Script element containing the JavaScript code.
"""
# Build HTMX auto-open listener if targets specified
htmx_listener = ""
if htmx_auto_open_targets:
conditions = " ||\n ".join(
f"evt.detail.target.id === '{target}'" for target in htmx_auto_open_targets
)
htmx_listener = f"""
// HTMX event: after loading content, open the panel
document.body.addEventListener('htmx:afterSwap', function(evt) {{
if ({conditions}) {{
{open_fn_name}();
}}
}});"""
return Script(f"""
function {open_fn_name}() {{
document.getElementById('{panel_id}').classList.add('open');
document.getElementById('{backdrop_id}').classList.add('open');
document.body.style.overflow = 'hidden';
// Focus the panel for keyboard events
document.getElementById('{panel_id}').focus();
}}
function {close_fn_name}() {{
document.getElementById('{panel_id}').classList.remove('open');
document.getElementById('{backdrop_id}').classList.remove('open');
document.body.style.overflow = '';
}}{htmx_listener}
""")

View File

@@ -1,12 +1,13 @@
# ABOUTME: Responsive sidebar and menu drawer components for AnimalTrack. # ABOUTME: Responsive sidebar and menu drawer components for AnimalTrack.
# ABOUTME: Desktop shows persistent sidebar, mobile shows slide-out drawer. # ABOUTME: Desktop shows persistent sidebar, mobile shows slide-out drawer.
from fasthtml.common import A, Button, Div, Nav, Script, Span, Style from fasthtml.common import A, Button, Div, Nav, Span, Style
from fasthtml.svg import Path, Svg from fasthtml.svg import Path, Svg
from animaltrack.build_info import get_build_info from animaltrack.build_info import get_build_info
from animaltrack.models.reference import UserRole from animaltrack.models.reference import UserRole
from animaltrack.web.templates.icons import EggIcon, FeedIcon, MoveIcon from animaltrack.web.templates.icons import EggIcon, FeedIcon, MoveIcon
from animaltrack.web.templates.shared_scripts import slide_over_script
def SidebarStyles(): # noqa: N802 def SidebarStyles(): # noqa: N802
@@ -73,21 +74,12 @@ def SidebarStyles(): # noqa: N802
def SidebarScript(): # noqa: N802 def SidebarScript(): # noqa: N802
"""JavaScript for menu drawer open/close behavior.""" """JavaScript for menu drawer open/close behavior."""
return Script(""" return slide_over_script(
function openMenuDrawer() { panel_id="menu-drawer",
document.getElementById('menu-drawer').classList.add('open'); backdrop_id="menu-backdrop",
document.getElementById('menu-backdrop').classList.add('open'); open_fn_name="openMenuDrawer",
document.body.style.overflow = 'hidden'; close_fn_name="closeMenuDrawer",
// Focus the drawer for keyboard events )
document.getElementById('menu-drawer').focus();
}
function closeMenuDrawer() {
document.getElementById('menu-drawer').classList.remove('open');
document.getElementById('menu-backdrop').classList.remove('open');
document.body.style.overflow = '';
}
""")
def _primary_nav_item(label: str, href: str, icon_fn, is_active: bool): def _primary_nav_item(label: str, href: str, icon_fn, is_active: bool):
@@ -264,6 +256,7 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
hx_on_click="closeMenuDrawer()", hx_on_click="closeMenuDrawer()",
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors", cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
type="button", type="button",
aria_label="Close menu",
), ),
cls="flex items-center justify-between px-4 py-4 border-b border-stone-800", cls="flex items-center justify-between px-4 py-4 border-b border-stone-800",
), ),
@@ -276,6 +269,8 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl", cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl",
tabindex="-1", tabindex="-1",
hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()", hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()",
role="dialog",
aria_label="Navigation menu",
), ),
cls="md:hidden", cls="md:hidden",
) )