Skip to content

Add beasts browsing page with filters and sorting#75

Merged
igor47 merged 3 commits into
mainfrom
igor/beasts-page
Mar 26, 2026
Merged

Add beasts browsing page with filters and sorting#75
igor47 merged 3 commits into
mainfrom
igor/beasts-page

Conversation

@igor47

@igor47 igor47 commented Mar 26, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add a top-level Beasts page (/beasts) with filters for ruleset (SRD 5.1/5.2), size, max CR, movement type, and name search
  • Sortable table columns (name, CR, size) with HTMX, using OOB swaps to preserve sort when changing filters
  • Fix sort order being lost when changing filters on the Spells page (same OOB swap technique)
  • Fix flat-only damage display (e.g. Rat's Bite showing "+ 1" instead of "1")
  • Generalize spells.jsdetail-modal.js for shared modal behavior across pages
  • Add integration tests for both Spells and Beasts list pages

Test plan

  • Visit /beasts — should show SRD 5.1 beasts by default, sorted by CR
  • Switch ruleset to SRD 5.2 — columns should not shift
  • Apply filters (size, max CR, movement) and verify results
  • Sort by a column, then change a filter — sort should be preserved
  • Click a beast name to open detail modal
  • Verify Rat's Bite shows "1 Piercing damage" (no leading +)
  • Visit /spells, sort by name, change school filter — sort preserved
  • mise run test src/routes/beasts.test.ts src/routes/spells.test.ts

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /beasts route with filtering (ruleset/size/max CR/movement/search) and sortable columns, plus modal deep-linking via openBeast.
  • Preserve sort state when changing filters on /spells (and /beasts) using HTMX OOB swaps.
  • Add Bun integration tests for /spells and /beasts list/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.

Comment thread static/detail-modal.js
Comment on lines +5 to +15

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);
});

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
});
}

Copilot uses AI. Check for mistakes.
Comment thread src/components/BeastsTable.tsx Outdated
Comment on lines +91 to +100
<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"
/>

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread static/detail-modal.js
Comment on lines +3 to +15
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);
});

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/components/SpellsTable.tsx Outdated
Comment on lines +70 to +73
<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" />

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/components/Beasts.tsx
Comment on lines +105 to +111
<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>
))}

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<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).

Copilot uses AI. Check for mistakes.
Comment thread src/components/Beasts.tsx Outdated
<option value="">Any Movement</option>
{MovementTypes.map((type) => (
<option value={type} selected={selectedMovement === type}>
<span class="text-capitalize">Can {type}</span>

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
<span class="text-capitalize">Can {type}</span>
Can {type}

Copilot uses AI. Check for mistakes.
Comment thread src/components/Spells.tsx Outdated
Comment on lines +149 to +150
{/* Character-specific scripts */}
<script src="/static/spells.js"></script>
<script src="/static/detail-modal.js"></script>

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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.

Copilot uses AI. Check for mistakes.
Comment thread src/components/Beasts.tsx Outdated
Comment on lines +164 to +166

{/* Client-side script for opening beast modal from URL */}
<script src="/static/detail-modal.js"></script>

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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.

Suggested change
{/* Client-side script for opening beast modal from URL */}
<script src="/static/detail-modal.js"></script>

Copilot uses AI. Check for mistakes.
Comment thread static/detail-modal.js
const url = new URL(window.location);
OPEN_PARAMS.forEach(p => url.searchParams.delete(p));
history.pushState(null, '', url);
});

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
});
});
// 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();
});

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
hx-target="#beasts-table"
hx-target="#beasts-table"
hx-swap="outerHTML"

Copilot uses AI. Check for mistakes.
Comment thread src/routes/beasts.tsx Outdated
Comment on lines +131 to +135
// 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}`)

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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).

Copilot uses AI. Check for mistakes.
Comment thread src/components/BeastDetail.tsx Outdated
Comment on lines +131 to +135
{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}`

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/components/Spells.tsx
Comment on lines 43 to +47
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) */}

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/components/Beasts.tsx
hx-get="/beasts"
hx-target="#beasts-table"
hx-trigger="input from:#beast-search-filter changed delay:300ms, change from:select"
hx-push-url="true"

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
hx-push-url="true"
hx-push-url="true"
hx-swap="outerHTML"

Copilot uses AI. Check for mistakes.
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/routes/beasts.tsx
Comment on lines +96 to +105
beasts={filteredBeasts}
sortBy={sortBy}
sortOrder={sortOrder}
selectedRuleset={rulesetParam}
selectedSize={sizeFilter}
selectedMaxCR={maxCRStr}
selectedMovement={movementFilter}
searchQuery={searchQuery}
/>
</>

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/routes/beasts.tsx
Comment on lines +109 to +122
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}
/>,

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/routes/beasts.tsx Outdated
Comment on lines 131 to 136
// 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}`)
}

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/routes/beasts.tsx Outdated
Comment on lines +11 to +18
const SIZE_ORDER: Record<string, number> = {
tiny: 0,
small: 1,
medium: 2,
large: 3,
huge: 4,
gargantuan: 5,
}

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
igor47 and others added 2 commits March 26, 2026 12:41
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>
@igor47 igor47 force-pushed the igor/beasts-page branch from 966b47b to e290fb4 Compare March 26, 2026 19:42
@igor47 igor47 merged commit 49a156d into main Mar 26, 2026
2 checks passed
@igor47 igor47 deleted the igor/beasts-page branch March 26, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants