Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8f54c06
fix(view): paginationNav anchor modes default to window-aware auto-su…
github-actions[bot] May 15, 2026
df9a3d6
docs(web/guides): document paginationNav anchor display modes and alw…
github-actions[bot] May 15, 2026
55d46b8
fix(view): address Reviewer A/B consensus findings (round 1)
github-actions[bot] May 15, 2026
f289924
chore(web): refresh visual baseline(s) (blog)
github-actions[bot] May 15, 2026
e18b6c9
Merge branch 'develop' into fix/bot-2716-paginationnav-default-anchor…
bpamiri May 15, 2026
7cd7211
Merge branch 'develop' into fix/bot-2716-paginationnav-default-anchor…
bpamiri May 16, 2026
713b9b6
Merge branch 'develop' into fix/bot-2716-paginationnav-default-anchor…
bpamiri May 16, 2026
61ef732
Merge branch 'develop' into fix/bot-2716-paginationnav-default-anchor…
bpamiri May 16, 2026
041e5ad
Merge branch 'develop' into fix/bot-2716-paginationnav-default-anchor…
bpamiri May 16, 2026
53ae352
chore(web): refresh visual baseline(s) (blog)
github-actions[bot] May 16, 2026
6c6dd4a
Merge branch 'develop' into fix/bot-2716-paginationnav-default-anchor…
bpamiri May 16, 2026
6b379e7
docs: address Reviewer A nits on CLAUDE.md pagination docs
bpamiri May 16, 2026
e7d0fcf
fix(view): address Reviewer A/B consensus findings (round 3)
github-actions[bot] May 16, 2026
8f0a8cc
Merge branch 'develop' into fix/bot-2716-paginationnav-default-anchor…
bpamiri May 16, 2026
dfd2725
chore(web): refresh visual baseline(s) (blog)
github-actions[bot] May 16, 2026
0c79f86
Merge branch 'develop' into fix/bot-2716-paginationnav-default-anchor…
bpamiri May 16, 2026
21c32a8
fix(view): address Reviewer A/B consensus findings (round 4)
github-actions[bot] May 16, 2026
e5ad65d
chore(web): refresh visual baseline(s) (blog)
github-actions[bot] May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo

### Fixed

- `paginationNav()` `showFirst` / `showLast` / `showPrevious` / `showNext` args now accept the tri-state strings `"auto"` / `"always"` / `"never"` (with backwards-compatible boolean coercion: `true` → `"always"`, `false` → `"never"`) and default to `"auto"`. Under `"auto"` the first/last anchors only render when the visible page-number window does not already reach the boundary — restoring the legacy 3.x `paginationLinks(alwaysShowAnchors=false)` semantics that a like-for-like swap to `paginationNav()` previously lost. Under `"auto"` the previous/next anchors always delegate to `previousPageLink()` / `nextPageLink()`, which render a disabled `<span class="disabled">` at the boundary by default — preserving the legacy `showPrevious=true` / `showNext=true` boundary indicator unless callers opt out with `"never"`. Adds a `windowSize` arg on `paginationNav()` so the auto-mode predicates stay coherent with `pageNumberLinks()`'s window (now passed explicitly to `pageNumberLinks()` instead of leaking through the anchor sub-helpers). Invalid strings throw `Wheels.InvalidArgument` at the call site
- `QueryBuilder.whereIn()` / `whereNotIn()` with an empty array no longer emit malformed SQL (`property IN ()`). Previously, passing an empty list or array to either method produced syntactically invalid SQL that surfaced as a generic JDBC syntax error from the database, with no pointer back to the call site that built the empty collection. `whereIn(prop, [])` now sets an `$alwaysEmpty` flag on the builder so every terminal method (`count`, `findAll`, `findOne`, `first`, `exists`, `updateAll`, `deleteAll`, `findEach`, `findInBatches`) short-circuits to the appropriate zero-row sentinel before going through the finder. `whereNotIn(prop, [])` is a no-op (exclude-none = match-all), so the chain proceeds normally. Matches the user-facing behaviour every mature ORM converged on (Rails, Sequel, Django, Laravel Eloquent: empty `IN` matches no rows, empty `NOT IN` matches every row). The flag-based design avoids a runtime trap from Wheels' WHERE-clause parser (`vendor/wheels/model/sql.cfc` runs a property-extraction regex over every clause it sees — a raw `1 = 0` literal would be parsed as property `1` and trip `Wheels.ColumnNotFound`). Fourteen new specs in `vendor/wheels/tests/specs/model/queryBuilderSpec.cfc` cover empty-array, empty-list, composition with other clauses, the `whereNotIn` mirrors, every patched terminal (`findAll`, `first` / `findOne`, `exists`, `count`, `updateAll`, `deleteAll`, `findEach`, `findInBatches`), and the documented `select()` / `include()` silent-ignore caveat on the short-circuit path. Both copies of the query-builder guide were updated to document the short-circuit in the methods table (#2736)
- `wheels mcp setup` now writes a stdio-based `.opencode.json` instead of one pointing at the deprecated HTTP MCP endpoint. `cli/src/templates/OpenCodeConfig.json` — the file the setup command actually reads from (`setup.cfc:53`) — still carried the pre-4.0 shape: `"url": "http://localhost:{PORT}/wheels/mcp", "type": "remote"`, with `{PORT}` left as an unsubstituted literal string. OpenCode users running `wheels mcp setup` ended up with a config trying to connect to a host called `{PORT}` against an endpoint that emits a deprecation warning on every call. The template now uses the same stdio form already shipped in `tools/build/base/.opencode.json`: `"type": "local", "command": ["wheels", "mcp", "wheels"]`. The companion monorepo reference copy at `app/snippets/OpenCodeConfig.json` (not read by the setup command, but kept in sync for consistency) was updated to match. The CHANGELOG entry from when the stdio shift originally landed claimed all template copies had been updated; this closes the two that were missed (#2735)
- `wheels packages --help` / `wheels packages help` / `wheels packages -h` now emit a module-owned help string that documents `add` as the canonical install verb and explains why typing `install` does not work (LuCLI's built-in extension installer intercepts the literal verb before dispatch reaches the module — same trap that hit `wheels browser install` → `wheels browser setup` in #2345). Previously the auto-introspected help drifted from the real CLI surface, advertising an `install <name> [--force]` row that never actually installed anything (#2713)
Expand Down
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,8 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
```cfm
// All-in-one nav (wraps first/prev/page-numbers/next/last in <nav>)
#paginationNav()#
#paginationNav(showInfo=true, showFirst=false, showLast=false, navClass="my-pagination")#
#paginationNav(showInfo=true, showFirst="never", showLast="never", navClass="my-pagination")#
#paginationNav(windowSize=3)# // widens window used by auto-mode boundary detection

// Bootstrap 5 — declarative preset: active class on <li>, current page as <span class="page-link">, aria-current
#paginationNav(viewStyle="bootstrap5")#
Expand All @@ -482,7 +483,9 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
#paginationNav(viewStyle="tailwind")#
#pageNumberLinks(viewStyle="tailwind")#

// Bootstrap 5 — manual composition (granular alternative to viewStyle preset, like-for-like swap for legacy paginationLinks())
// Bootstrap 5 — manual composition (granular alternative to viewStyle preset, like-for-like swap for legacy paginationLinks()).
// Pass showFirst="always" / showLast="always" to restore the 3.x alwaysShowAnchors=true
// behaviour; omit them to keep the new "auto" boundary-hide default.
#paginationNav(
navClass="",
prepend='<ul class="pagination">',
Expand All @@ -504,11 +507,13 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
#pageNumberLinks(windowSize=5, classForCurrent="active", addActiveClassToPrependedParent=true)#
```

`showFirst` / `showLast` / `showPrevious` / `showNext` accept `"auto"` (default), `"always"`, or `"never"`. Under `"auto"` the first/last anchors are hidden when the visible page-number window already reaches the boundary. Under `"auto"` the previous/next anchors always delegate to `previousPageLink()` / `nextPageLink()` — those render a disabled `<span class="disabled">` at the boundary so the position indicator is preserved; use `"never"` to hide the boundary indicator entirely. Booleans coerce for backwards compatibility (`true` → `"always"`, `false` → `"never"`).

`viewStyle` accepts `"plain"` (default, preserves original output), `"bootstrap5"`, `"bootstrap4"`, or `"tailwind"`. Bootstrap presets emit `<li class="page-item active" aria-current="page"><span class="page-link">N</span></li>` for the current page, with the active class on the `<li>` wrapper — no `Replace()` post-processing needed. Non-plain presets ignore `prependToPage`, `appendToPage`, `classForCurrent`, `class`, `prepend`, `append`, and `anchorDivider` in favour of the preset markup.

Disabled links render as `<span class="disabled">` by default. All helpers accept `handle` for named pagination queries. `paginationNav()` also accepts `prepend`/`append` (HTML inside `<nav>` before/after the link list), `prependToPage`/`appendToPage` (per-anchor wrappers applied to all navigation anchors including first/prev/next/last), `addActiveClassToPrependedParent` (injects `active ` into the current-page `prependToPage` class attribute), and `anchorDivider` (separator between sections, default `" "`) — these compose into the same Bootstrap-style output as the `viewStyle="bootstrap5"` preset but with finer-grained control.

In development (`showErrorInformation = true`), `paginationNav()` throws `Wheels.PaginationNav.InvalidArgument` if passed an argument not accepted by any of its sub-helpers. Accepted pass-through keys: `format`, `text`, `name`, `class`, `disabledClass`, `showDisabled`, `pageNumberAsParam`, `windowSize`, `classForCurrent`, `linkToCurrentPage`, `prependToPage`, `appendToPage`, `addActiveClassToPrependedParent`, `route`, `controller`, `action`, `key`, `anchor`, `onlyPath`, `host`, `protocol`, `port`, `params`. Named route segment variables (e.g., `userId` when `route="userTimeline"` and the pattern contains `[userId]`) are automatically exempted from this check. In production the unknown argument is silently dropped.
In development (`showErrorInformation = true`), `paginationNav()` throws `Wheels.PaginationNav.InvalidArgument` if passed an argument not accepted by any of its sub-helpers. `windowSize` is a first-class `paginationNav()` argument (like `navClass` / `showInfo`) and is consumed internally — it is not forwarded to the anchor sub-helpers. Accepted pass-through keys: `format`, `text`, `name`, `class`, `disabledClass`, `showDisabled`, `pageNumberAsParam`, `classForCurrent`, `linkToCurrentPage`, `prependToPage`, `appendToPage`, `addActiveClassToPrependedParent`, `route`, `controller`, `action`, `key`, `anchor`, `onlyPath`, `host`, `protocol`, `port`, `params`. Named route segment variables (e.g., `userId` when `route="userTimeline"` and the pattern contains `[userId]`) are automatically exempted from this check. In production the unknown argument is silently dropped.

## Testing Quick Reference

Expand Down
9 changes: 5 additions & 4 deletions vendor/wheels/events/init/functions.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -313,12 +313,13 @@
};
application.$wheels.functions.paginationNav = {
navClass = "pagination",
showFirst = true,
showLast = true,
showPrevious = true,
showNext = true,
showFirst = "auto",
showLast = "auto",
showPrevious = "auto",
showNext = "auto",
showInfo = false,
showSinglePage = false,
windowSize = 2,
viewStyle = "plain",
prepend = "",
append = "",
Expand Down
147 changes: 142 additions & 5 deletions vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,10 @@ component extends="wheels.WheelsTest" {

it("wraps first/previous/next/last in <li class='page-item'> for Bootstrap 5", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
result = _controller.paginationNav(viewStyle = "bootstrap5")
// Default showFirst/showLast = "auto" auto-suppresses these when the windowed page
// list already reaches the boundary (page 2 with windowSize=2 renders pages 1-4),
// so opt in explicitly to test the preset's anchor wrapping markup.
result = _controller.paginationNav(viewStyle = "bootstrap5", showFirst = "always", showLast = "always")
expect(result).toInclude('<li class="page-item">')
expect(result).toInclude('First')
expect(result).toInclude('Previous')
Expand Down Expand Up @@ -330,7 +333,9 @@ component extends="wheels.WheelsTest" {

it("emits Tailwind pagination-disabled span for first/previous when on first page", () => {
g.model("author").findAll(page = 1, perPage = 3, order = "lastName")
result = _controller.paginationNav(viewStyle = "tailwind")
// Opt into showFirst="always" since auto-mode suppresses First when the rendered
// page-number window already includes page 1.
result = _controller.paginationNav(viewStyle = "tailwind", showFirst = "always")
expect(result).toInclude('<span class="pagination-disabled">')
expect(result).toInclude('First')
expect(result).toInclude('Previous')
Expand Down Expand Up @@ -364,6 +369,18 @@ component extends="wheels.WheelsTest" {
}).toThrow("Wheels.InvalidViewStyle")
})

it("forwards windowSize to pageNumberLinks() on the viewStyle path", () => {
g.setPagination(totalRecords = 100, currentPage = 5, perPage = 10)
result = _controller.paginationNav(viewStyle = "bootstrap5", windowSize = 4)
// windowSize=4 expands the rendered page-number window to pages 1-9,
// so page 8 must appear in the output. If windowSize were silently
// dropped from the $renderPaginationNav() → pageNumberLinks() call
// (default 2), the window would shrink to 3-7 and page 8 would
// disappear — guards against the auto-mode predicate and rendered
// window using mismatched windowSize values.
expect(result).toInclude(">8<")
})

})

/* ── paginationNav ─────────────────────────── */
Expand All @@ -378,8 +395,8 @@ component extends="wheels.WheelsTest" {
expect(result).toInclude("pagination")
})

it("includes all sections by default", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
it("includes all sections by default when window does not reach boundaries", () => {
g.setPagination(totalRecords = 100, currentPage = 5, perPage = 10)
result = _controller.paginationNav()
expect(result).toInclude("First")
expect(result).toInclude("Previous")
Expand Down Expand Up @@ -503,10 +520,14 @@ component extends="wheels.WheelsTest" {

it("wraps every anchor (first/prev/page/next/last) with prependToPage and appendToPage", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
// Explicit "always" overrides the auto-mode defaults so this test
// exercises wrapping regardless of where the page-number window sits.
result = _controller.paginationNav(
prependToPage = '<li class="page-item">',
appendToPage = '</li>',
class = "page-link"
class = "page-link",
showFirst = "always",
showLast = "always"
)
// One <li> for first, prev, each numbered page, next, last
expect(ListLen(result, "<")).toBeGT(5)
Expand Down Expand Up @@ -554,6 +575,122 @@ component extends="wheels.WheelsTest" {

})

/* ── paginationNav anchor display modes ────── */

describe("paginationNav anchor display modes", () => {

it("hides First in auto mode when window already reaches first page", () => {
g.setPagination(totalRecords = 12, currentPage = 2, perPage = 3)
result = _controller.paginationNav()
expect(result).notToInclude("First")
})

it("hides Last in auto mode when window already reaches last page", () => {
g.setPagination(totalRecords = 12, currentPage = 3, perPage = 3)
result = _controller.paginationNav()
expect(result).notToInclude("Last")
})

it("shows First in auto mode when window does not reach first page", () => {
g.setPagination(totalRecords = 100, currentPage = 10, perPage = 10)
result = _controller.paginationNav()
expect(result).toInclude("First")
})

it("shows Last in auto mode when window does not reach last page", () => {
g.setPagination(totalRecords = 100, currentPage = 1, perPage = 10)
result = _controller.paginationNav()
expect(result).toInclude("Last")
})

it("renders First with always mode when window already reaches first page", () => {
g.setPagination(totalRecords = 12, currentPage = 2, perPage = 3)
result = _controller.paginationNav(showFirst = "always")
expect(result).toInclude("First")
})

it("hides First with never mode when window does not reach first page", () => {
g.setPagination(totalRecords = 100, currentPage = 10, perPage = 10)
result = _controller.paginationNav(showFirst = "never")
expect(result).notToInclude("First")
})

it("treats boolean true as always for backwards compatibility", () => {
g.setPagination(totalRecords = 12, currentPage = 2, perPage = 3)
result = _controller.paginationNav(showFirst = true, showLast = true)
expect(result).toInclude("First")
expect(result).toInclude("Last")
})

it("treats boolean false as never for backwards compatibility", () => {
g.setPagination(totalRecords = 100, currentPage = 5, perPage = 10)
result = _controller.paginationNav(showFirst = false, showLast = false)
expect(result).notToInclude("First")
expect(result).notToInclude("Last")
})

it("respects windowSize when computing auto mode predicates", () => {
g.setPagination(totalRecords = 100, currentPage = 5, perPage = 10)
result = _controller.paginationNav(windowSize = 4)
expect(result).notToInclude("First")
// windowSize=4 expands the rendered page-number window to pages 1-9,
// so page 8 must appear in the output. If windowSize were silently
// dropped from the pageNumberLinks() call (default 2), the window
// would shrink to 3-7 and page 8 would disappear — the notToInclude
// "First" assertion alone would still pass since the First-hide
// predicate is evaluated on arguments.windowSize directly.
expect(result).toInclude(">8<")
})

it("renders disabled Previous span in auto mode on first page", () => {
g.setPagination(totalRecords = 100, currentPage = 1, perPage = 10)
result = _controller.paginationNav()
expect(result).toInclude("Previous")
expect(result).toInclude("disabled")
})

it("renders Previous link in auto mode when not on first page", () => {
g.setPagination(totalRecords = 100, currentPage = 3, perPage = 10)
result = _controller.paginationNav()
expect(result).toInclude("Previous")
expect(result).toInclude("<a")
})

it("renders disabled Next span in auto mode on last page", () => {
g.setPagination(totalRecords = 100, currentPage = 10, perPage = 10)
result = _controller.paginationNav()
expect(result).toInclude("Next")
expect(result).toInclude("disabled")
})

it("renders Next link in auto mode when not on last page", () => {
g.setPagination(totalRecords = 100, currentPage = 3, perPage = 10)
result = _controller.paginationNav()
expect(result).toInclude("Next")
expect(result).toInclude("<a")
})

it("hides Previous entirely with never mode on first page", () => {
g.setPagination(totalRecords = 100, currentPage = 1, perPage = 10)
result = _controller.paginationNav(showPrevious = "never")
expect(result).notToInclude("Previous")
})

it("hides Next entirely with never mode on last page", () => {
g.setPagination(totalRecords = 100, currentPage = 10, perPage = 10)
result = _controller.paginationNav(showNext = "never")
expect(result).notToInclude("Next")
})

it("rejects unknown anchor mode strings", () => {
g.setPagination(totalRecords = 100, currentPage = 5, perPage = 10)
expect(() => {
_controller.paginationNav(showFirst = "bogus")
}).toThrow()
})

})

})

}
Expand Down
Loading