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.services.animal import AnimalService, ValidationError
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 (
attrs_diff_panel,
attrs_form,
@@ -206,9 +206,11 @@ async def animal_cohort(request: Request, session):
)
# 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,
cohort_form(locations, species_list),
push_url="/actions/cohort",
title="Create Cohort - AnimalTrack",
active_nav=None,
)
@@ -349,9 +351,11 @@ async def hatch_recorded(request: Request, session):
)
# 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,
hatch_form(locations, species_list),
push_url="/actions/hatch",
title="Record Hatch - AnimalTrack",
active_nav=None,
)
@@ -690,9 +694,11 @@ async def animal_tag_add(request: Request, session):
)
# 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,
tag_add_form(),
push_url="/actions/tag-add",
title="Add Tag - AnimalTrack",
active_nav=None,
)
@@ -939,9 +945,11 @@ async def animal_tag_end(request: Request, session):
)
# 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,
tag_end_form(),
push_url="/actions/tag-end",
title="End Tag - AnimalTrack",
active_nav=None,
)
@@ -1175,9 +1183,11 @@ async def animal_attrs(request: Request, session):
)
# 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,
attrs_form(),
push_url="/actions/attrs",
title="Update Attributes - AnimalTrack",
active_nav=None,
)
@@ -1455,10 +1465,11 @@ async def animal_outcome(request: Request, session):
)
# Success: re-render fresh form
# Use render_page_post to set HX-Push-Url header for correct browser URL
product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
return render_page(
return render_page_post(
request,
outcome_form(
filter_str="",
@@ -1468,6 +1479,7 @@ async def animal_outcome(request: Request, session):
resolved_count=0,
products=products,
),
push_url="/actions/outcome",
title="Record Outcome - AnimalTrack",
active_nav=None,
)
@@ -1678,7 +1690,8 @@ async def animal_status_correct(req: Request, session):
)
# 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,
status_correct_form(
filter_str="",
@@ -1687,6 +1700,7 @@ async def animal_status_correct(req: Request, session):
ts_utc=int(time.time() * 1000),
resolved_count=0,
),
push_url="/actions/status-correct",
title="Correct Status - AnimalTrack",
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.users import UserRepository
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
# 30 days in milliseconds
@@ -365,7 +365,14 @@ async def product_collected(request: Request, session):
# Validate location_id
if not location_id:
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
@@ -373,12 +380,26 @@ async def product_collected(request: Request, session):
quantity = int(quantity_str)
except ValueError:
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:
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)
@@ -389,7 +410,14 @@ async def product_collected(request: Request, session):
if not resolved_ids:
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
@@ -422,7 +450,16 @@ async def product_collected(request: Request, session):
route="/actions/product-collected",
)
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)
if UserRepository(db).get(actor):
@@ -446,7 +483,8 @@ async def product_collected(request: Request, session):
display_data = _get_eggs_display_data(db, locations)
# 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,
eggs_page(
locations,
@@ -457,6 +495,7 @@ async def product_collected(request: Request, session):
sell_action=product_sold,
**display_data,
),
push_url="/",
title="Eggs - AnimalTrack",
active_nav="eggs",
)
@@ -486,19 +525,48 @@ async def product_sold(request: Request, session):
# Validate 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
try:
quantity = int(quantity_str)
except ValueError:
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:
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
@@ -506,12 +574,30 @@ async def product_sold(request: Request, session):
total_price_cents = int(total_price_str)
except ValueError:
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:
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)
@@ -544,7 +630,18 @@ async def product_sold(request: Request, session):
route="/actions/product-sold",
)
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_toast(
@@ -557,7 +654,8 @@ async def product_sold(request: Request, session):
display_data = _get_eggs_display_data(db, locations)
# 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,
eggs_page(
locations,
@@ -568,13 +666,23 @@ async def product_sold(request: Request, session):
sell_action=product_sold,
**display_data,
),
push_url="/",
title="Eggs - AnimalTrack",
active_nav="eggs",
)
def _render_harvest_error(request, db, locations, products, selected_location_id, error_message):
"""Render harvest form with error message.
def _render_harvest_error(
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:
request: The HTTP request.
@@ -583,6 +691,8 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
products: List of sellable products.
selected_location_id: Currently selected location.
error_message: Error message to display.
quantity: Quantity value to preserve.
notes: Notes value to preserve.
Returns:
HTMLResponse with 422 status.
@@ -600,6 +710,8 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
harvest_error=error_message,
harvest_action=product_collected,
sell_action=product_sold,
harvest_quantity=quantity,
harvest_notes=notes,
**display_data,
),
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):
"""Render sell form with error message.
def _render_sell_error(
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:
request: The HTTP request.
@@ -620,6 +743,10 @@ def _render_sell_error(request, db, locations, products, selected_product_code,
products: List of sellable products.
selected_product_code: Currently selected product code.
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:
HTMLResponse with 422 status.
@@ -637,6 +764,10 @@ def _render_sell_error(request, db, locations, products, selected_product_code,
sell_error=error_message,
harvest_action=product_collected,
sell_action=product_sold,
sell_quantity=quantity,
sell_total_price_cents=total_price_cents,
sell_buyer=buyer,
sell_notes=notes,
**display_data,
),
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.users import UserRepository
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
# 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)
# 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,
feed_page(
locations,
@@ -512,6 +513,7 @@ async def feed_given(request: Request, session):
purchase_action=feed_purchased,
**display_data,
),
push_url="/feed",
title="Feed - AnimalTrack",
active_nav="feed",
)
@@ -666,7 +668,8 @@ async def feed_purchased(request: Request, session):
display_data = _get_feed_display_data(db, locations, feed_types)
# 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,
feed_page(
locations,
@@ -676,6 +679,7 @@ async def feed_purchased(request: Request, session):
purchase_action=feed_purchased,
**display_data,
),
push_url="/feed",
title="Feed - AnimalTrack",
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.validation import SelectionContext, validate_selection
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
# Milliseconds per day
@@ -396,13 +396,15 @@ async def animal_move(request: Request, session):
display_data = _get_move_display_data(db, locations)
# 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,
move_form(
locations,
action=animal_move,
**display_data,
),
push_url="/move",
title="Move - AnimalTrack",
active_nav="move",
)

View File

@@ -1,7 +1,7 @@
# ABOUTME: Templates package for AnimalTrack web UI.
# 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
__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 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 (
Alert,
AlertT,
@@ -12,7 +12,6 @@ from monsterui.all import (
ButtonT,
FormLabel,
LabelInput,
LabelSelect,
LabelTextArea,
)
from ulid import ULID
@@ -21,6 +20,103 @@ from animaltrack.models.animals import Animal
from animaltrack.models.reference import Location, Species
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
# =============================================================================
@@ -45,43 +141,14 @@ def event_datetime_field(
Returns:
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
has_initial = bool(initial_value)
picker_style = "display: block;" if has_initial else "display: none;"
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(
# Load static JS for datetime picker functionality
Script(src="/static/v1/datetime-picker.js"),
FormLabel("Event Time"),
Div(
P(
@@ -90,29 +157,30 @@ def event_datetime_field(
Span(
toggle_text,
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",
),
Div(
Input(
id=input_id,
name=f"{field_id}_value",
type="datetime-local",
value=initial_value,
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(
"Select date/time for this event (leave empty for current time)",
cls="text-xs text-stone-500 mt-1",
),
id=picker_id,
data_datetime_picker=field_id,
style=picker_style,
),
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",
)
@@ -215,19 +283,17 @@ def cohort_form(
H2("Create Animal Cohort", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Species dropdown
LabelSelect(
*species_options,
label="Species",
id="species",
name="species",
# Species dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Species", _for="species"),
Select(*species_options, name="species", id="species", cls="uk-select"),
cls="space-y-2",
),
# Location dropdown
LabelSelect(
*location_options,
label="Location",
id="location_id",
name="location_id",
# Location dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Location", _for="location_id"),
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
cls="space-y-2",
),
# Count input
LabelInput(
@@ -239,26 +305,23 @@ def cohort_form(
value=count_value,
placeholder="Number of animals",
),
# Life stage dropdown
LabelSelect(
*life_stage_options,
label="Life Stage",
id="life_stage",
name="life_stage",
# Life stage dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Life Stage", _for="life_stage"),
Select(*life_stage_options, name="life_stage", id="life_stage", cls="uk-select"),
cls="space-y-2",
),
# Sex dropdown
LabelSelect(
*sex_options,
label="Sex",
id="sex",
name="sex",
# Sex dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Sex", _for="sex"),
Select(*sex_options, name="sex", id="sex", cls="uk-select"),
cls="space-y-2",
),
# Origin dropdown
LabelSelect(
*origin_options,
label="Origin",
id="origin",
name="origin",
# Origin dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Origin", _for="origin"),
Select(*origin_options, name="origin", id="origin", cls="uk-select"),
cls="space-y-2",
),
# Optional notes
LabelTextArea(
@@ -272,7 +335,7 @@ def cohort_form(
# Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())),
# 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)
action=action,
method="post",
@@ -350,19 +413,17 @@ def hatch_form(
H2("Record Hatch", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Species dropdown
LabelSelect(
*species_options,
label="Species",
id="species",
name="species",
# Species dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Species", _for="species"),
Select(*species_options, name="species", id="species", cls="uk-select"),
cls="space-y-2",
),
# Hatch location dropdown
LabelSelect(
*location_options,
label="Hatch Location",
id="location_id",
name="location_id",
# Hatch location dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Hatch Location", _for="location_id"),
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
cls="space-y-2",
),
# Hatched count input
LabelInput(
@@ -374,13 +435,17 @@ def hatch_form(
value=hatched_live_value,
placeholder="Number hatched",
),
# Brood location dropdown (optional)
# Brood location dropdown (optional) - using raw Select due to MonsterUI LabelSelect value bug
Div(
LabelSelect(
*brood_location_options,
label="Brood Location (optional)",
id="assigned_brood_location_id",
name="assigned_brood_location_id",
Div(
FormLabel("Brood Location (optional)", _for="assigned_brood_location_id"),
Select(
*brood_location_options,
name="assigned_brood_location_id",
id="assigned_brood_location_id",
cls="uk-select",
),
cls="space-y-2",
),
P(
"If different from hatch location, hatchlings will be placed here",
@@ -399,7 +464,7 @@ def hatch_form(
# Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())),
# 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)
action=action,
method="post",
@@ -526,6 +591,7 @@ def promote_form(
"Save Changes" if is_rename else "Promote to Identified",
type="submit",
cls=ButtonT.primary,
hx_disabled_elt="this",
),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
@@ -652,7 +718,7 @@ def tag_add_form(
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# 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)
action=action,
method="post",
@@ -683,60 +749,17 @@ def tag_add_diff_panel(
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
]
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",
),
return diff_confirmation_panel(
diff=diff,
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
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(
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",
action_hidden_fields=[("tag", tag)],
cancel_url="/actions/tag-add",
confirm_button_text=f"Confirm Tag ({diff.server_count} animals)",
question_text=f"Would you like to proceed with tagging {diff.server_count} animals as '{tag}'?",
)
@@ -845,12 +868,11 @@ def tag_end_form(
),
# Selection container - updated via HTMX when filter changes
selection_container,
# Tag dropdown
LabelSelect(
*tag_options,
label="Tag to End",
id="tag",
name="tag",
# Tag dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Tag to End", _for="tag"),
Select(*tag_options, name="tag", id="tag", cls="uk-select"),
cls="space-y-2",
)
if active_tags
else Div(
@@ -872,7 +894,13 @@ def tag_end_form(
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# 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)
action=action,
method="post",
@@ -903,60 +931,17 @@ def tag_end_diff_panel(
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
]
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",
),
return diff_confirmation_panel(
diff=diff,
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
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(
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",
action_hidden_fields=[("tag", tag)],
cancel_url="/actions/tag-end",
confirm_button_text=f"Confirm End Tag ({diff.server_count} animals)",
question_text=f"Would you like to proceed with ending tag '{tag}' on {diff.server_count} animals?",
)
@@ -1115,7 +1100,7 @@ def attrs_form(
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# 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)
action=action,
method="post",
@@ -1150,62 +1135,21 @@ def attrs_diff_panel(
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
]
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",
),
return diff_confirmation_panel(
diff=diff,
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
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(
f"Would you like to proceed with updating {diff.server_count} animals?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
action_hidden_fields=[
("sex", sex or ""),
("life_stage", life_stage or ""),
("repro_status", repro_status or ""),
],
cancel_url="/actions/attrs",
confirm_button_text=f"Confirm Update ({diff.server_count} animals)",
question_text=f"Would you like to proceed with updating {diff.server_count} animals?",
)
@@ -1318,20 +1262,22 @@ def outcome_form(
yield_section = Div(
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"),
# Using raw Select due to MonsterUI LabelSelect value bug
Div(
LabelSelect(
*product_options,
label="Product",
id="yield_product_code",
name="yield_product_code",
cls="flex-1",
Div(
FormLabel("Product", _for="yield_product_code"),
Select(
*product_options,
name="yield_product_code",
id="yield_product_code",
cls="uk-select",
),
cls="space-y-2 flex-1",
),
LabelSelect(
*unit_options,
label="Unit",
id="yield_unit",
name="yield_unit",
cls="w-32",
Div(
FormLabel("Unit", _for="yield_unit"),
Select(*unit_options, name="yield_unit", id="yield_unit", cls="uk-select"),
cls="space-y-2 w-32",
),
cls="flex gap-3",
),
@@ -1377,13 +1323,11 @@ def outcome_form(
),
# Selection container - updated via HTMX when filter changes
selection_container,
# Outcome selection
LabelSelect(
*outcome_options,
label="Outcome",
id="outcome",
name="outcome",
required=True,
# Outcome selection - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Outcome", _for="outcome"),
Select(*outcome_options, name="outcome", id="outcome", cls="uk-select", required=True),
cls="space-y-2",
),
# Reason field
LabelInput(
@@ -1410,7 +1354,7 @@ def outcome_form(
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# 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)
action=action,
method="post",
@@ -1451,65 +1395,25 @@ def outcome_diff_panel(
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
]
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",
),
return diff_confirmation_panel(
diff=diff,
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
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(
f"Would you like to proceed with recording {outcome} for {diff.server_count} animals?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
action_hidden_fields=[
("outcome", outcome),
("reason", reason or ""),
("yield_product_code", yield_product_code or ""),
("yield_unit", yield_unit or ""),
("yield_quantity", str(yield_quantity) if yield_quantity else ""),
("yield_weight_kg", str(yield_weight_kg) if yield_weight_kg else ""),
],
cancel_url="/actions/outcome",
confirm_button_text=f"Confirm Outcome ({diff.server_count} animals)",
question_text=f"Would you like to proceed with recording {outcome} for {diff.server_count} animals?",
confirm_button_cls=ButtonT.destructive,
)
@@ -1599,13 +1503,13 @@ def status_correct_form(
value=filter_str,
placeholder="e.g., species:duck location:Coop1",
),
# New status selection
LabelSelect(
*status_options,
label="New Status",
id="new_status",
name="new_status",
required=True,
# New status selection - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("New Status", _for="new_status"),
Select(
*status_options, name="new_status", id="new_status", cls="uk-select", required=True
),
cls="space-y-2",
),
# Reason field (required for admin actions)
LabelInput(
@@ -1631,7 +1535,7 @@ def status_correct_form(
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# 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)
action=action,
method="post",
@@ -1664,59 +1568,19 @@ def status_correct_diff_panel(
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
]
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",
),
return diff_confirmation_panel(
diff=diff,
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
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(
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",
action_hidden_fields=[
("new_status", new_status),
("reason", reason),
],
cancel_url="/actions/status-correct",
confirm_button_text=f"Confirm Correction ({diff.server_count} animals)",
question_text=f"Would you like to proceed with correcting status to {new_status} for {diff.server_count} animals?",
confirm_button_cls=ButtonT.destructive,
)

View File

@@ -1,11 +1,12 @@
# ABOUTME: Base HTML template for AnimalTrack pages.
# 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 animaltrack.models.reference import UserRole
from animaltrack.web.templates.nav import BottomNav, BottomNavStyles
from animaltrack.web.templates.shared_scripts import slide_over_script
from animaltrack.web.templates.sidebar import (
MenuDrawer,
Sidebar,
@@ -79,29 +80,13 @@ def EventSlideOverStyles(): # noqa: N802
def EventSlideOverScript(): # noqa: N802
"""JavaScript for event slide-over panel open/close behavior."""
return Script("""
function openEventPanel() {
document.getElementById('event-slide-over').classList.add('open');
document.getElementById('event-backdrop').classList.add('open');
document.body.style.overflow = 'hidden';
// Focus the panel for keyboard events
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();
}
});
""")
return slide_over_script(
panel_id="event-slide-over",
backdrop_id="event-backdrop",
open_fn_name="openEventPanel",
close_fn_name="closeEventPanel",
htmx_auto_open_targets=["event-slide-over", "event-panel-content"],
)
def CsrfHeaderScript(): # noqa: N802
@@ -157,6 +142,8 @@ def EventSlideOver(): # noqa: N802
"shadow-2xl border-l border-stone-700 overflow-hidden",
tabindex="-1",
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",
),
# 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
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,
**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 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 (
Button,
ButtonT,
FormLabel,
LabelInput,
LabelSelect,
LabelTextArea,
TabContainer,
)
@@ -37,6 +37,13 @@ def eggs_page(
cost_per_egg: float | None = None,
sales_stats: dict | 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.
@@ -56,6 +63,12 @@ def eggs_page(
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'.
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:
Page content with tabbed forms.
@@ -85,6 +98,8 @@ def eggs_page(
eggs_per_day=eggs_per_day,
cost_per_egg=cost_per_egg,
location_names=location_names,
default_quantity=harvest_quantity,
default_notes=harvest_notes,
),
cls="uk-active" if harvest_active else None,
),
@@ -96,6 +111,10 @@ def eggs_page(
action=sell_action,
recent_events=sell_events,
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",
),
@@ -113,6 +132,8 @@ def harvest_form(
eggs_per_day: float | None = None,
cost_per_egg: float | None = None,
location_names: dict[str, str] | None = None,
default_quantity: str | None = None,
default_notes: str | None = None,
) -> Div:
"""Create the Harvest form for egg collection.
@@ -125,6 +146,8 @@ def harvest_form(
eggs_per_day: 30-day average eggs per day.
cost_per_egg: 30-day average cost per egg in EUR.
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:
Div containing form and recent events section.
@@ -177,12 +200,11 @@ def harvest_form(
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Location dropdown
LabelSelect(
*location_options,
label="Location",
id="location_id",
name="location_id",
# Location dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Location", _for="location_id"),
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
cls="space-y-2",
),
# Quantity input (integer only, 0 allowed for "checked but found none")
LabelInput(
@@ -194,6 +216,7 @@ def harvest_form(
step="1",
placeholder="Number of eggs",
required=True,
value=default_quantity or "",
),
# Optional notes
LabelTextArea(
@@ -201,13 +224,14 @@ def harvest_form(
id="notes",
name="notes",
placeholder="Optional notes",
value=default_notes or "",
),
# Event datetime picker (for backdating)
event_datetime_field("harvest_datetime"),
# Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())),
# 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)
action=action,
method="post",
@@ -232,6 +256,10 @@ def sell_form(
action: Callable[..., Any] | str = "/actions/product-sold",
recent_events: list[tuple[Event, bool]] | 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:
"""Create the Sell form for recording sales.
@@ -242,6 +270,10 @@ def sell_form(
action: Route function or URL string for form submission.
recent_events: Recent (Event, is_deleted) tuples, most recent first.
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:
Div containing form and recent events section.
@@ -300,12 +332,11 @@ def sell_form(
H2("Sell Products", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Product dropdown
LabelSelect(
*product_options,
label="Product",
id="product_code",
name="product_code",
# Product dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Product", _for="product_code"),
Select(*product_options, name="product_code", id="product_code", cls="uk-select"),
cls="space-y-2",
),
# Quantity input (integer only, min=1)
LabelInput(
@@ -317,6 +348,7 @@ def sell_form(
step="1",
placeholder="Number of items sold",
required=True,
value=default_quantity or "",
),
# Total price in cents
LabelInput(
@@ -328,6 +360,7 @@ def sell_form(
step="1",
placeholder="Total price in cents",
required=True,
value=default_total_price_cents or "",
),
# Optional buyer
LabelInput(
@@ -336,6 +369,7 @@ def sell_form(
name="buyer",
type="text",
placeholder="Optional buyer name",
value=default_buyer or "",
),
# Optional notes
LabelTextArea(
@@ -343,13 +377,14 @@ def sell_form(
id="sell_notes",
name="notes",
placeholder="Optional notes",
value=default_notes or "",
),
# Event datetime picker (for backdating)
event_datetime_field("sell_datetime"),
# Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())),
# 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)
action=action,
method="post",

View File

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

View File

@@ -47,7 +47,7 @@ def location_list(
placeholder="Enter location name",
),
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_target="#location-list",
hx_swap="outerHTML",
@@ -160,7 +160,7 @@ def rename_form(
Hidden(name="nonce", value=str(uuid4())),
DivFullySpaced(
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_target="#location-list",

View File

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

View File

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

View File

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

View File

@@ -107,7 +107,12 @@ def registry_header(filter_str: str, total_count: int) -> Div:
),
# Buttons container
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)
A(
"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: 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 animaltrack.build_info import get_build_info
from animaltrack.models.reference import UserRole
from animaltrack.web.templates.icons import EggIcon, FeedIcon, MoveIcon
from animaltrack.web.templates.shared_scripts import slide_over_script
def SidebarStyles(): # noqa: N802
@@ -73,21 +74,12 @@ def SidebarStyles(): # noqa: N802
def SidebarScript(): # noqa: N802
"""JavaScript for menu drawer open/close behavior."""
return Script("""
function openMenuDrawer() {
document.getElementById('menu-drawer').classList.add('open');
document.getElementById('menu-backdrop').classList.add('open');
document.body.style.overflow = 'hidden';
// 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 = '';
}
""")
return slide_over_script(
panel_id="menu-drawer",
backdrop_id="menu-backdrop",
open_fn_name="openMenuDrawer",
close_fn_name="closeMenuDrawer",
)
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()",
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
type="button",
aria_label="Close menu",
),
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",
tabindex="-1",
hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()",
role="dialog",
aria_label="Navigation menu",
),
cls="md:hidden",
)