Add beasts browsing page with filters and sorting#75
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a dedicated /beasts browsing experience (filters + sortable table + modal details) and aligns list-page sorting behavior across Beasts and Spells via HTMX out-of-band swaps, with new integration tests.
Changes:
- Add
/beastsroute with filtering (ruleset/size/max CR/movement/search) and sortable columns, plus modal deep-linking viaopenBeast. - Preserve sort state when changing filters on
/spells(and/beasts) using HTMX OOB swaps. - Add Bun integration tests for
/spellsand/beastslist/detail routes; adjust beast damage display formatting.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| static/spells.js | Removed page-specific modal URL behavior (replaced by shared script). |
| static/detail-modal.js | Repurposed to open modal from openSpell/openBeast URL params and clean up params on close. |
| src/routes/spells.test.ts | Adds integration tests for spells list filtering, HTMX table-only responses, and redirects. |
| src/routes/beasts.tsx | Implements /beasts list route with filters/sort + HTMX table response; redirects non-HTMX detail to list with openBeast. |
| src/routes/beasts.test.ts | Adds integration tests for beasts list filtering/HTMX behavior and updated non-HTMX redirect expectations. |
| src/components/ui/Navbar.tsx | Adds “Beasts” nav link. |
| src/components/SpellsTable.tsx | Adds OOB hidden inputs intended to keep sort params synced with the filter form. |
| src/components/Spells.tsx | Updates sort hidden inputs to have IDs and switches to shared modal script. |
| src/components/BeastsTable.tsx | New sortable, HTMX-driven beasts table with modal deep-links and OOB sort-state syncing. |
| src/components/Beasts.tsx | New beasts page with filter form, table, and detail modal. |
| src/components/BeastDetail.tsx | Fixes flat-only damage rendering (avoid displaying “+ N” without dice). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| if (OPEN_PARAMS.some(p => urlParams.has(p))) { | ||
| const modal = new bootstrap.Modal(document.getElementById('detailModal')); | ||
| modal.show(); | ||
| } | ||
|
|
||
| document.getElementById('detailModal').addEventListener('hidden.bs.modal', () => { | ||
| const url = new URL(window.location); | ||
| OPEN_PARAMS.forEach(p => url.searchParams.delete(p)); | ||
| history.pushState(null, '', url); | ||
| }); |
There was a problem hiding this comment.
The current URL-param auto-open will show the modal whenever openSpell/openBeast exists, even if the server didn't render any modal content (e.g. invalid IDs), resulting in an empty modal. Consider only calling modal.show() when #detailModalContent has actual content, or when the matching entity was found and rendered.
| if (OPEN_PARAMS.some(p => urlParams.has(p))) { | |
| const modal = new bootstrap.Modal(document.getElementById('detailModal')); | |
| modal.show(); | |
| } | |
| document.getElementById('detailModal').addEventListener('hidden.bs.modal', () => { | |
| const url = new URL(window.location); | |
| OPEN_PARAMS.forEach(p => url.searchParams.delete(p)); | |
| history.pushState(null, '', url); | |
| }); | |
| const modalElement = document.getElementById('detailModal'); | |
| const modalContentElement = document.getElementById('detailModalContent'); | |
| if ( | |
| modalElement && | |
| modalContentElement && | |
| modalContentElement.innerHTML.trim().length > 0 && | |
| OPEN_PARAMS.some(p => urlParams.has(p)) | |
| ) { | |
| const modal = new bootstrap.Modal(modalElement); | |
| modal.show(); | |
| } | |
| if (modalElement) { | |
| modalElement.addEventListener('hidden.bs.modal', () => { | |
| const url = new URL(window.location); | |
| OPEN_PARAMS.forEach(p => url.searchParams.delete(p)); | |
| history.pushState(null, '', url); | |
| }); | |
| } |
| <div id="beasts-table"> | ||
| {/* OOB swaps to keep the filter form's hidden sort inputs in sync */} | ||
| <input type="hidden" id="beastSortBy" name="sortBy" value={sortBy} hx-swap-oob="true" /> | ||
| <input | ||
| type="hidden" | ||
| id="beastSortOrder" | ||
| name="sortOrder" | ||
| value={sortOrder} | ||
| hx-swap-oob="true" | ||
| /> |
There was a problem hiding this comment.
Same issue as the spells table: Beasts.tsx renders #beastSortBy/#beastSortOrder inside the filter form, and BeastsTable also renders elements with the same IDs for OOB swapping. This creates duplicate IDs on initial page render and can break OOB behavior. Gate these OOB inputs so they only appear in the HTMX response variant of the table.
| document.addEventListener('DOMContentLoaded', () => { | ||
| const urlParams = new URLSearchParams(window.location.search); | ||
|
|
||
| if (OPEN_PARAMS.some(p => urlParams.has(p))) { | ||
| const modal = new bootstrap.Modal(document.getElementById('detailModal')); | ||
| modal.show(); | ||
| } | ||
|
|
||
| document.getElementById('detailModal').addEventListener('hidden.bs.modal', () => { | ||
| const url = new URL(window.location); | ||
| OPEN_PARAMS.forEach(p => url.searchParams.delete(p)); | ||
| history.pushState(null, '', url); | ||
| }); |
There was a problem hiding this comment.
detail-modal.js assumes #detailModal always exists. Since this script is included globally (Layout includes /static/detail-modal.js), pages without a DetailModal will throw when calling new bootstrap.Modal(document.getElementById('detailModal')) or document.getElementById('detailModal').addEventListener(...). Add null checks (and early-return) when detailModal is missing.
| <div id="spells-table"> | ||
| {/* OOB swaps to keep the filter form's hidden sort inputs in sync */} | ||
| <input type="hidden" id="sortBy" name="sortBy" value={sortBy} hx-swap-oob="true" /> | ||
| <input type="hidden" id="sortOrder" name="sortOrder" value={sortOrder} hx-swap-oob="true" /> |
There was a problem hiding this comment.
The OOB hidden inputs (id="sortBy"/id="sortOrder") are rendered unconditionally inside SpellsTable. On a full page load, Spells.tsx also renders hidden inputs with the same IDs inside the filter form, creating duplicate IDs in the DOM (invalid HTML) and making OOB swapping/getElementById behavior unpredictable. Render these OOB inputs only for HTMX table responses (e.g. via an includeOob prop set by the route when HX-Request is true).
| <select id="size-filter" name="size" class="form-select"> | ||
| <option value="">All Sizes</option> | ||
| {Sizes.map((size) => ( | ||
| <option value={size} selected={selectedSize === size}> | ||
| <span class="text-capitalize">{size}</span> | ||
| </option> | ||
| ))} |
There was a problem hiding this comment.
<option> elements should not contain nested markup like <span>; only text content is valid. The <span class="text-capitalize">…</span> inside the size options will be dropped/reshaped by browsers. Render plain text (and apply capitalization via CSS on the <select>/<option> if needed).
| <option value="">Any Movement</option> | ||
| {MovementTypes.map((type) => ( | ||
| <option value={type} selected={selectedMovement === type}> | ||
| <span class="text-capitalize">Can {type}</span> |
There was a problem hiding this comment.
Same <option> issue here: the movement options include a <span> inside <option>, which is invalid HTML and can lead to inconsistent rendering. Use plain text for the option label (e.g. Can fly).
| <span class="text-capitalize">Can {type}</span> | |
| Can {type} |
| {/* Character-specific scripts */} | ||
| <script src="/static/spells.js"></script> | ||
| <script src="/static/detail-modal.js"></script> |
There was a problem hiding this comment.
/static/detail-modal.js is already included globally in Layout (so it runs on every page). Including it again here will load/execute it twice, registering duplicate event listeners. Remove this page-level <script> tag and rely on the global include.
|
|
||
| {/* Client-side script for opening beast modal from URL */} | ||
| <script src="/static/detail-modal.js"></script> |
There was a problem hiding this comment.
/static/detail-modal.js is included globally in Layout, so including it again here will execute it twice and duplicate event listeners. Drop this page-level <script> tag and rely on the global include.
| {/* Client-side script for opening beast modal from URL */} | |
| <script src="/static/detail-modal.js"></script> |
| const url = new URL(window.location); | ||
| OPEN_PARAMS.forEach(p => url.searchParams.delete(p)); | ||
| history.pushState(null, '', url); | ||
| }); |
There was a problem hiding this comment.
detail-modal.js no longer listens for the closeDetailModal event (triggered via HX-Trigger: closeDetailModal in multiple character routes), so modals opened via HTMX workflows will no longer close after successful form submissions. Restore the document.body.addEventListener('closeDetailModal', ...) handler (and hide the Bootstrap modal instance) to preserve existing behavior.
| }); | |
| }); | |
| // Restore support for closing the modal via a custom event, e.g. from HTMX HX-Trigger: closeDetailModal | |
| document.body.addEventListener('closeDetailModal', () => { | |
| const modalElement = document.getElementById('detailModal'); | |
| if (!modalElement) { | |
| return; | |
| } | |
| let modalInstance = bootstrap.Modal.getInstance(modalElement); | |
| if (!modalInstance) { | |
| modalInstance = new bootstrap.Modal(modalElement); | |
| } | |
| modalInstance.hide(); | |
| }); |
08b7d31 to
597ff84
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| class="btn btn-link p-0 text-white text-decoration-none d-flex align-items-center gap-1" | ||
| style="cursor: pointer;" | ||
| hx-get={`/beasts?${params.toString()}`} | ||
| hx-target="#beasts-table" |
There was a problem hiding this comment.
The sortable header buttons also target #beasts-table, but the server returns a full <div id="beasts-table">…</div> wrapper via BeastsTable. With the default HTMX swap style this will create nested #beasts-table elements after the first sort/filter interaction. Add hx-swap="outerHTML" here (and on the filter form) or adjust the response markup so the swap target receives only inner content.
| hx-target="#beasts-table" | |
| hx-target="#beasts-table" | |
| hx-swap="outerHTML" |
| // Get ruleset from query param, default to srd52 | ||
| const ruleset = (c.req.query("ruleset") || "srd52") as RulesetId | ||
|
|
||
| // If not HTMX, redirect to beasts list (when we have one) | ||
| if (!isHtmxRequest) { | ||
| // For now, just return the beast detail directly | ||
| const beast = getBeastById(ruleset, beastId) | ||
|
|
||
| if (!beast) { | ||
| return c.text("Beast not found", 404) | ||
| } | ||
|
|
||
| return c.render(<BeastDetail beast={beast} />, { title: beast.name }) | ||
| return c.redirect(`/beasts?openBeast=${beastId}&ruleset=${ruleset}`) |
There was a problem hiding this comment.
/beasts/:id defaults ruleset to srd52, but the new /beasts list defaults to srd51 (and the PR description says SRD 5.1 is the default). Deep-linking to /beasts/:id without a ruleset will therefore land users on a different default ruleset than the main page. Consider normalizing/sanitizing the ruleset param the same way as /beasts and defaulting consistently (and build the redirect URL via URLSearchParams/encoding to avoid query injection via unexpected IDs).
| {action.damage.dice ? formatDice(action.damage.dice) : ""} | ||
| {action.damage.flatBonus ? ` + ${action.damage.flatBonus}` : ""}{" "} | ||
| {action.damage.flatBonus | ||
| ? action.damage.dice | ||
| ? ` + ${action.damage.flatBonus}` | ||
| : ` ${action.damage.flatBonus}` |
There was a problem hiding this comment.
In the flat-damage-only case (no dice), this introduces a double-space after Hit: because both <em>Hit:</em>{" "} and the flat bonus string include leading whitespace (and there’s also a trailing {" "}). Consider restructuring the spacing so the rendered text is consistently single-spaced (e.g., build the damage string separately and render it once).
| hx-trigger="input from:#search-filter changed delay:300ms, change from:select" | ||
| hx-push-url="true" | ||
| class="row g-3 mb-4" | ||
| > | ||
| {/* Hidden inputs to preserve sort state */} | ||
| <input type="hidden" name="sortBy" value={sortBy || "level"} /> | ||
| <input type="hidden" name="sortOrder" value={sortOrder || "asc"} /> | ||
| {/* Hidden inputs to preserve sort state (updated via OOB swaps from SpellsTable) */} |
There was a problem hiding this comment.
The HTMX updates for the table target #spells-table, but SpellsTable renders a wrapper <div id="spells-table">…</div>. With HTMX’s default innerHTML swap this will nest a second #spells-table inside the first (duplicate IDs / invalid DOM) on every filter/sort update. Consider swapping outerHTML (e.g., add hx-swap="outerHTML" on the form + sort header buttons) or change the HTMX response to return only the table contents (no wrapper div).
| hx-get="/beasts" | ||
| hx-target="#beasts-table" | ||
| hx-trigger="input from:#beast-search-filter changed delay:300ms, change from:select" | ||
| hx-push-url="true" |
There was a problem hiding this comment.
Same swap issue as the Spells page: this form targets #beasts-table, while BeastsTable renders a wrapper <div id="beasts-table">…</div>. Without hx-swap="outerHTML", HTMX will default to innerHTML and you’ll end up nesting a second #beasts-table inside the first (duplicate IDs) after the first filter change. Add hx-swap="outerHTML" (and keep header sort buttons consistent) or have the HTMX response return only the inner table markup.
| hx-push-url="true" | |
| hx-push-url="true" | |
| hx-swap="outerHTML" |
The filter form's hidden sortBy/sortOrder inputs were static from initial render. When sorting changed via table headers (HTMX swap), the hidden inputs kept stale values, so the next filter change lost the sort. Added HTMX out-of-band swaps in SpellsTable to sync the hidden inputs whenever the table is replaced. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
597ff84 to
966b47b
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| beasts={filteredBeasts} | ||
| sortBy={sortBy} | ||
| sortOrder={sortOrder} | ||
| selectedRuleset={rulesetParam} | ||
| selectedSize={sizeFilter} | ||
| selectedMaxCR={maxCRStr} | ||
| selectedMovement={movementFilter} | ||
| searchQuery={searchQuery} | ||
| /> | ||
| </> |
There was a problem hiding this comment.
rulesetParam is used as selectedRuleset and fed back into generated URLs, even when it’s an invalid value (anything other than "srd52" is coerced to srd51 for data). This can lead to non-canonical URLs like ruleset=foo being persisted across sorting/filtering. Consider normalizing to a single canonical value (e.g., pass the sanitized ruleset/normalized string into selectedRuleset and OOB inputs).
| const openBeast = openBeastId ? getBeasts(ruleset).find((b) => b.id === openBeastId) : undefined | ||
|
|
||
| return c.render( | ||
| <Beasts | ||
| beasts={filteredBeasts} | ||
| selectedRuleset={rulesetParam} | ||
| selectedSize={sizeFilter} | ||
| selectedMaxCR={maxCRStr} | ||
| selectedMovement={movementFilter} | ||
| searchQuery={searchQuery} | ||
| sortBy={sortBy} | ||
| sortOrder={sortOrder} | ||
| openBeast={openBeast} | ||
| />, |
There was a problem hiding this comment.
When openBeast is not found for the given openBeast query param, the page will still auto-open the modal (via detail-modal.js) but render it with no content, which is confusing UX and makes invalid shared links look broken. Consider rendering the same "Beast Not Found" modal content on the list page when openBeastId is present but invalid, or stripping the openBeast param during render/redirect when it can’t be resolved.
| // Get ruleset from query param, default to srd51 (consistent with /beasts list) | ||
| const ruleset = (c.req.query("ruleset") || "srd51") as RulesetId | ||
|
|
||
| // If not HTMX, redirect to beasts list (when we have one) | ||
| if (!isHtmxRequest) { | ||
| // For now, just return the beast detail directly | ||
| const beast = getBeastById(ruleset, beastId) | ||
|
|
||
| if (!beast) { | ||
| return c.text("Beast not found", 404) | ||
| } | ||
|
|
||
| return c.render(<BeastDetail beast={beast} />, { title: beast.name }) | ||
| return c.redirect(`/beasts?openBeast=${beastId}&ruleset=${ruleset}`) | ||
| } |
There was a problem hiding this comment.
ruleset in /beasts/:id is taken directly from the query string and cast to RulesetId. Even though downstream code currently treats unknown values as srd51, it still allows invalid values to leak into redirects/URLs. Consider reusing the same normalization logic as the list route (only allow "srd51"/"srd52" and default to "srd51") before using it and before including it in the redirect URL.
| const SIZE_ORDER: Record<string, number> = { | ||
| tiny: 0, | ||
| small: 1, | ||
| medium: 2, | ||
| large: 3, | ||
| huge: 4, | ||
| gargantuan: 5, | ||
| } |
There was a problem hiding this comment.
SIZE_ORDER is typed as Record<string, number>, which makes it easy to introduce typos that only show up at runtime (and forces the sort to silently fall back to 0). Consider typing it as Record<SizeType, number> (or satisfies Record<SizeType, number>) so missing/extra keys are caught at compile time.
Add a top-level Beasts page similar to the Spells page, with filters for ruleset (SRD 5.1 default, SRD 5.2), size, max CR, movement type, and name search. Table columns (name, CR, size) are sortable. Beast detail opens in the shared modal. Also adds integration tests for both the beasts and spells list pages, and generalizes the modal JS (spells.js -> detail-modal.js) to handle both openSpell and openBeast URL params. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a beast attack has no dice (e.g. Rat's Bite does 1 piercing), the damage displayed as "+ 1" instead of just "1". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
966b47b to
e290fb4
Compare
Summary
/beasts) with filters for ruleset (SRD 5.1/5.2), size, max CR, movement type, and name searchspells.js→detail-modal.jsfor shared modal behavior across pagesTest plan
/beasts— should show SRD 5.1 beasts by default, sorted by CR/spells, sort by name, change school filter — sort preservedmise run test src/routes/beasts.test.ts src/routes/spells.test.ts🤖 Generated with Claude Code