Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -32,6 +32,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
- `wheels` `.deb` / `.rpm` Linux packages now ship the lucli-native `wheels-module` artifact, version + channel stamps, and a wrapper that routes through the bundled module — fixing the three v4.0.0 rpm regressions that broke `wheels start` on Rocky Linux during the titan production cutover. (1) `build-linux-packages.sh` now untars `wheels-module-${WHEELS_VERSION}.tar.gz` into `/opt/wheels/module/` instead of unzipping the CommandBox-shaped `wheels-cli-${WHEELS_VERSION}.zip`. (2) The LuCLI binary is staged as `/opt/wheels/wheels` so `basename(argv[0])` is `wheels` when the wrapper execs it — mirroring the brew formula and making LuCLI's module dispatcher resolve `wheels start` against the bundled module. (3) `nfpm-wheels.yaml` and `nfpm-wheels-be.yaml` now declare `/opt/wheels/.version` and `/opt/wheels/.channel` under `contents:` so `wheels --version` no longer returns `unknown (stable)`. (4) `tar` is declared as an rpm + deb runtime dependency since Rocky Linux 10 minimal cloud images do not ship it and any role that unpacks a tarball payload fails silently without it (#2700)
- `wheels.middleware.RateLimiter` now validates `windowSeconds > 0` and `maxRequests >= 0` at construction. Previously, `windowSeconds = 0` leaked a generic CFML `You cannot divide by zero` exception out of the `fixedWindow` and `tokenBucket` strategies (and let every request through on `slidingWindow`), with no pointer back to the misconfigured `set(middleware = [...])` line. The constructor now throws `Wheels.RateLimiter.InvalidConfiguration` with a message naming the bad parameter — matching the pattern already used for `strategy`, `storage`, and `proxyStrategy`. `maxRequests = 0` remains legal (kill-switch idiom for "block every request") (#2693)
- `wheels deploy --version=v1.2.3` (the form documented in the Kamal migration guide) no longer fails with `Invalid value for option '--version': 'v1.2.3' is not a boolean`. picocli treats `--version` as a `versionHelp = true` root flag and absorbs it during arg parsing before `Module.cfc` ever sees the subcommand, so the literal Kamal form was unreachable. The deploy parser now accepts `--release` as a picocli-safe alias (extracted into `cli/lucli/services/deploy/cli/DeployArgsParser.cfc` for unit-testability), and the brew/scoop wrappers rewrite `--version[=val]` → `--release[=val]` when `deploy` is the first positional — so the documented `--version` form keeps working on a current-channel wrapper, and users on an older wrapper can pass `--release` directly (#2674)
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,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

// Individual helpers for custom layouts
#paginationInfo()# // "Showing 26-50 of 1,000 records"
Expand All @@ -474,7 +475,7 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
#pageNumberLinks(windowSize=5, classForCurrent="active")#
```

Disabled links render as `<span class="disabled">` by default. All helpers accept `handle` for named pagination queries.
`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"`). Disabled links render as `<span class="disabled">` by default. All helpers accept `handle` for named pagination queries.

## 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 @@ -311,12 +311,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,
encode = true
};
application.$wheels.functions.paginationLinks = {
Expand Down
113 changes: 111 additions & 2 deletions vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,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 @@ -282,6 +282,115 @@ 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")
})

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
99 changes: 84 additions & 15 deletions vendor/wheels/view/pagination.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -311,28 +311,39 @@ component {
* Creates a complete pagination navigation element wrapping individual pagination helpers.
* Outputs a `<nav>` element containing first/previous/page-numbers/next/last links and optional info text.
*
* The `showFirst` / `showLast` / `showPrevious` / `showNext` args accept the
* strings `"auto"`, `"always"`, or `"never"`. Booleans are normalized for
* backwards compatibility: `true` maps to `"always"`, `false` maps to `"never"`.
* Under `"auto"` the first/last anchors only render when the visible page-number
* window does not already reach the boundary (matching legacy 3.x semantics).
* Under `"auto"` the previous/next anchors always delegate to their sub-helper,
* which renders a disabled `<span class="disabled">` at the boundary by default —
* use `"never"` to suppress the boundary indicator entirely.
*
* [section: View Helpers]
* [category: Pagination Functions]
*
* @handle The handle given to the query that the pagination should be displayed for.
* @navClass CSS class for the wrapping nav element.
* @showFirst Whether to show the first page link.
* @showLast Whether to show the last page link.
* @showPrevious Whether to show the previous page link.
* @showNext Whether to show the next page link.
* @showFirst Anchor display mode for the first page link: "auto" (default), "always", "never", or boolean.
* @showLast Anchor display mode for the last page link: "auto" (default), "always", "never", or boolean.
* @showPrevious Anchor display mode for the previous page link: "auto" (default), "always", "never", or boolean.
* @showNext Anchor display mode for the next page link: "auto" (default), "always", "never", or boolean.
* @showInfo Whether to show the pagination info text.
* @showSinglePage Whether to show pagination when there is only one page.
* @windowSize Number of page links shown around the current page in `pageNumberLinks` and used by the auto-mode predicates.
* @encode [see:styleSheetLinkTag].
*/
public string function paginationNav(
string handle = "query",
string navClass,
boolean showFirst,
boolean showLast,
boolean showPrevious,
boolean showNext,
any showFirst,
any showLast,
any showPrevious,
any showNext,
boolean showInfo,
boolean showSinglePage,
numeric windowSize,
any encode
) {
$args(name = "paginationNav", args = arguments);
Expand All @@ -347,39 +358,46 @@ component {
local.subArgs = {};
local.subArgs.handle = arguments.handle;
local.subArgs.encode = arguments.encode;
// Pass through any extra arguments (route, controller, action, key, params, etc.)
local.skipArgs = "handle,navClass,showFirst,showLast,showPrevious,showNext,showInfo,showSinglePage,encode";
// Pass through any extra arguments (route, controller, action, key, params, etc.).
// `windowSize` is excluded because the anchor sub-helpers do not declare it; it
// is delivered explicitly to `pageNumberLinks()` below.
local.skipArgs = "handle,navClass,showFirst,showLast,showPrevious,showNext,showInfo,showSinglePage,windowSize,encode";
for (local.key in arguments) {
if (!ListFindNoCase(local.skipArgs, local.key)) {
local.subArgs[local.key] = arguments[local.key];
}
}

local.firstMode = $paginationAnchorMode(value = arguments.showFirst, argName = "showFirst");
local.lastMode = $paginationAnchorMode(value = arguments.showLast, argName = "showLast");
local.previousMode = $paginationAnchorMode(value = arguments.showPrevious, argName = "showPrevious");
local.nextMode = $paginationAnchorMode(value = arguments.showNext, argName = "showNext");

local.content = "";

if (arguments.showInfo) {
local.content &= paginationInfo(argumentCollection = local.subArgs);
local.content &= " ";
}

if (arguments.showFirst) {
if ($paginationShouldShowAnchor(mode = local.firstMode, side = "first", pg = local.pg, windowSize = arguments.windowSize)) {
local.content &= firstPageLink(argumentCollection = local.subArgs);
local.content &= " ";
}

if (arguments.showPrevious) {
if ($paginationShouldShowAnchor(mode = local.previousMode, side = "previous", pg = local.pg, windowSize = arguments.windowSize)) {
local.content &= previousPageLink(argumentCollection = local.subArgs);
local.content &= " ";
}

local.content &= pageNumberLinks(argumentCollection = local.subArgs);
local.content &= pageNumberLinks(argumentCollection = local.subArgs, windowSize = arguments.windowSize);

if (arguments.showNext) {
if ($paginationShouldShowAnchor(mode = local.nextMode, side = "next", pg = local.pg, windowSize = arguments.windowSize)) {
local.content &= " ";
local.content &= nextPageLink(argumentCollection = local.subArgs);
}

if (arguments.showLast) {
if ($paginationShouldShowAnchor(mode = local.lastMode, side = "last", pg = local.pg, windowSize = arguments.windowSize)) {
local.content &= " ";
local.content &= lastPageLink(argumentCollection = local.subArgs);
}
Expand All @@ -392,6 +410,57 @@ component {
);
}

/**
* Internal: normalizes a showFirst/showLast/showPrevious/showNext value into "auto" | "always" | "never".
* Booleans are coerced: true -> "always", false -> "never". Strings are matched case-insensitively.
* Public access required so $integrateComponents() pulls it into the view mixin scope on Lucee/Adobe.
*/
public string function $paginationAnchorMode(required any value, string argName = "anchor mode") {
if (IsBoolean(arguments.value)) {
return arguments.value ? "always" : "never";
}
if (ListFindNoCase("auto,always,never", arguments.value)) {
return LCase(arguments.value);
}
Throw(
type = "Wheels.InvalidArgument",
message = "Invalid pagination anchor mode '#arguments.value#' for argument '#arguments.argName#'.",
detail = "The argument must be one of 'auto', 'always', 'never', or a boolean."
);
}

/**
* Internal: decides whether a first/previous/next/last anchor should render
* given a normalized mode and the current pagination state.
*
* Under "auto" the first/last anchors only render when the visible page-number
* window does not already reach the boundary. Previous/next under "auto" always
* delegate to their sub-helper (`previousPageLink()` / `nextPageLink()`), which
* renders a disabled `<span class="disabled">` at the boundary by default —
* matching the legacy `showPrevious=true` / `showNext=true` behavior so the
* boundary indicator is preserved unless the caller opts out with `"never"`.
*/
public boolean function $paginationShouldShowAnchor(
required string mode,
required string side,
required struct pg,
required numeric windowSize
) {
if (arguments.mode == "never") {
return false;
}
if (arguments.mode == "always") {
return true;
}
switch (arguments.side) {
case "first":
return (arguments.pg.currentPage - arguments.windowSize) > 1;
case "last":
return arguments.pg.totalPages > (arguments.pg.currentPage + arguments.windowSize);
}
return true;
}

/**
* Internal: renders a disabled span element for pagination.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ See [Packages](/v4-0-1-snapshot/digging-deeper/packages/) for manifest fields, p

### Monolithic `paginationLinks()`

**CHANGELOG:** composable pagination helpers added (#1930). The 3.x `paginationLinks()` is retained. New code should use `paginationNav()` or compose the individual helpers (`paginationInfo`, `firstPageLink`, `previousPageLink`, `pageNumberLinks`, `nextPageLink`, `lastPageLink`).
**CHANGELOG:** composable pagination helpers added (#1930). The 3.x `paginationLinks()` is retained. New code should use `paginationNav()` or compose the individual helpers (`paginationInfo`, `firstPageLink`, `previousPageLink`, `pageNumberLinks`, `nextPageLink`, `lastPageLink`). The 3.x `alwaysShowAnchors=false` behavior — hiding first/last when the page-number window already reaches the boundary — is now the default in `paginationNav()` via `showFirst="auto"` / `showLast="auto"` (default since v4.0.1, #2716). Pass `showFirst="always"` / `showLast="always"` to force the old always-visible behavior. The previous/next anchors under the new `showPrevious="auto"` / `showNext="auto"` defaults continue to delegate to `previousPageLink()` / `nextPageLink()`, which render a disabled `<span class="disabled">` at the boundary — matching the legacy `showPrevious=true` / `showNext=true` indicator. Use `showPrevious="never"` / `showNext="never"` to hide the boundary indicator entirely.

### RocketUnit test style

Expand Down
Binary file modified web/tests/visual-baselines/blog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading