Skip to content

Commit 1588e32

Browse files
wheels-bot[bot]github-actions[bot]bpamiri
authored
feat(view): add viewStyle preset to paginationNav and pageNumberLinks (#2731)
* feat(view): add viewStyle preset to paginationNav and pageNumberLinks for Bootstrap markup The viewStyle argument accepts "plain" (default), "bootstrap5", "bootstrap4", or "tailwind". Bootstrap presets emit the canonical <nav><ul class="pagination"><li class="page-item active" aria-current="page"> <span class="page-link">N</span></li> structure — active class on the <li> wrapper, <span> for the current page — so Bootstrap-styled apps no longer need a Replace() regex hack to move the active class off the anchor. Default remains "plain" to preserve today's output byte-for-byte. Fixes #2718 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs: document viewStyle preset in CLAUDE.md pagination quick reference Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(view): address Reviewer A/B consensus findings (round 1) - vendor/wheels/view/pagination.cfc: encode arguments.text via EncodeForHTML when encode=true in the Bootstrap/Tailwind disabled branches of $renderPaginationNavLink. Previously local.encode was resolved but ignored, causing custom HTML-entity nav text (e.g. &laquo;) to render literally in the preset path while being decoded in the plain path. - vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc: replace the weak Tailwind pageNumberLinks assertion (which passed even if Bootstrap markup was accidentally emitted) with Tailwind-specific class assertions (pagination-current, pagination-link), plus paginationNav coverage for viewStyle="bootstrap4" (active class on <li>, no aria-current) and viewStyle="tailwind" (flat <nav class="pagination"> with no <ul>, pagination-disabled spans on first page), and a showInfo=true positioning spec for Bootstrap 5. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(view): throw Wheels.InvalidViewStyle on unknown viewStyle (round 2) Reviewer A flagged in round 2 that the three internal $renderPagination* helpers silently emit structurally broken HTML when handed an unknown viewStyle value (e.g. a typo like "boostrap5"). The call-site guard `local.useViewStyle = Len(arguments.viewStyle) && arguments.viewStyle != "plain"` already filters out the empty/plain case, so reaching the default branch is by definition a developer typo. Replace the silent fallbacks with a Wheels.InvalidViewStyle throw so the failure is loud and immediate at development time instead of surfacing as garbled output. Specs cover both the pageNumberLinks() and paginationNav() public entry points, asserting the typed throw using the existing toThrow matcher pattern from assetsSpec/viteSpec. Round-2 carry-over per Reviewer A; round-1 address-review intentionally skipped this per Reviewer B's "nice-to-have" classification, but Reviewer A held it open as still required. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs: list bootstrap4 and tailwind under paginationNav too Reviewer A's round-2 docs nit: the CLAUDE.md "Pagination View Helpers" quick reference showed paginationNav with bootstrap5 only, while bootstrap4 and tailwind appeared only under pageNumberLinks. Both presets are equally valid on both helpers, so add the paginationNav companion lines for the two missing presets. Signed-off-by: Peter Amiri <peter@alurium.com> * docs: extend viewStyle ignore list with prepend/append/anchorDivider Reviewer A round-6 docs gap: the CLAUDE.md "Non-plain presets ignore..." list was written before PR #2730 added prepend/append/anchorDivider args. Those args also land in local.skipArgs and are NOT forwarded into $renderPaginationNav(), so the preset path silently drops them. Add the three names to the explicit ignore list so anyone reading the docs sequentially sees the full picture. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com>
1 parent 5a4a299 commit 1588e32

5 files changed

Lines changed: 390 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
2222

2323
### Added
2424

25+
- `paginationNav()` and `pageNumberLinks()` now accept a `viewStyle` argument with named CSS-framework presets (`"plain"`, `"bootstrap5"`, `"bootstrap4"`, `"tailwind"`). Bootstrap presets emit the canonical `<nav><ul class="pagination"><li class="page-item active" aria-current="page"><span class="page-link">N</span></li>` structure — with the active class on the `<li>` wrapper and a `<span>` (not anchor) for the current page — so Bootstrap-styled apps no longer need a `Replace()` regex hack to move the active class off the anchor. `viewStyle` defaults to `"plain"`, preserving today's output byte-for-byte (#2718)
2526
- Docs: added "Reading the Changelog" guide page under the Upgrading section explaining where `CHANGELOG.md` lives (repo root, not inside `vendor/wheels/`), how to look up PR references cited in upgrade guides, and how to access the changelog offline when working with a vendored copy of the framework (#2719)
2627
- Document CORS allow-list defaults drift when migrating from 3.x `set(accessControlAllow*)` global settings to `wheels.middleware.Cors`; add header comparison table, explicit-constructor-args fix, and common-issues entry to the 3.x→4.x upgrade guide and a migration callout to the CORS reference page (#2708)
2728
- `PackageLoader` now derives a per-package CFML mapping from `package.json` and reflects it into `application.mappings`, so CFCs inside a hyphenated package (e.g. `vendor/wheels-sentry/`) can reference siblings via a static identifier (`new wheelsSentry.SentryClient()`) instead of `CreateObject("component", "vendor.wheels-sentry.SentryClient")`. The alias defaults to lower-camel-case of the manifest `name` (`wheels-sentry``wheelsSentry`, `wheels_legacy_adapter``wheelsLegacyAdapter`) and is overridable via a `mapping` field in `package.json`. Two packages computing the same alias are caught at load time — the first claimant keeps the mapping and the second is recorded in `getFailedPackages()` so the conflict is visible. Exposed via `PackageLoader.getPackageMappings()` (#2712)

CLAUDE.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,19 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
470470
#paginationNav()#
471471
#paginationNav(showInfo=true, showFirst=false, showLast=false, navClass="my-pagination")#
472472
473-
// Bootstrap 5 — like-for-like swap for legacy paginationLinks() Bootstrap markup
473+
// Bootstrap 5 — declarative preset: active class on <li>, current page as <span class="page-link">, aria-current
474+
#paginationNav(viewStyle="bootstrap5")#
475+
#pageNumberLinks(viewStyle="bootstrap5")#
476+
477+
// Bootstrap 4 — declarative preset: same as bootstrap5 but omits aria-current
478+
#paginationNav(viewStyle="bootstrap4")#
479+
#pageNumberLinks(viewStyle="bootstrap4")#
480+
481+
// Tailwind — declarative preset: flat structure with pagination-current / pagination-link utility classes
482+
#paginationNav(viewStyle="tailwind")#
483+
#pageNumberLinks(viewStyle="tailwind")#
484+
485+
// Bootstrap 5 — manual composition (granular alternative to viewStyle preset, like-for-like swap for legacy paginationLinks())
474486
#paginationNav(
475487
navClass="",
476488
prepend='<ul class="pagination">',
@@ -492,7 +504,9 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
492504
#pageNumberLinks(windowSize=5, classForCurrent="active", addActiveClassToPrependedParent=true)#
493505
```
494506

495-
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 `" "`).
507+
`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.
508+
509+
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.
496510

497511
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.
498512

vendor/wheels/events/init/functions.cfm

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@
308308
appendToPage = "",
309309
addActiveClassToPrependedParent = false,
310310
pageNumberAsParam = true,
311+
viewStyle = "plain",
311312
encode = true
312313
};
313314
application.$wheels.functions.paginationNav = {
@@ -318,6 +319,7 @@
318319
showNext = true,
319320
showInfo = false,
320321
showSinglePage = false,
322+
viewStyle = "plain",
321323
prepend = "",
322324
append = "",
323325
prependToPage = "",

vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,147 @@ component extends="wheels.WheelsTest" {
225225

226226
})
227227

228+
/* ── pageNumberLinks viewStyle presets ─────── */
229+
230+
describe("pageNumberLinks with viewStyle presets", () => {
231+
232+
it("emits Bootstrap 5 markup with active class on <li> wrapper and <span> for current page", () => {
233+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
234+
result = _controller.pageNumberLinks(viewStyle = "bootstrap5")
235+
expect(result).toInclude('<li class="page-item active" aria-current="page">')
236+
expect(result).toInclude('<span class="page-link">2</span>')
237+
expect(result).toInclude('<li class="page-item">')
238+
expect(result).toInclude('class="page-link"')
239+
})
240+
241+
it("emits Bootstrap 4 markup with active class on <li> wrapper but no aria-current", () => {
242+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
243+
result = _controller.pageNumberLinks(viewStyle = "bootstrap4")
244+
expect(result).toInclude('<li class="page-item active">')
245+
expect(result).notToInclude('aria-current')
246+
expect(result).toInclude('<span class="page-link">2</span>')
247+
})
248+
249+
it("emits Tailwind markup with pagination-current/pagination-link wrappers", () => {
250+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
251+
result = _controller.pageNumberLinks(viewStyle = "tailwind")
252+
expect(result).toInclude('<span class="pagination-current" aria-current="page">')
253+
expect(result).toInclude('class="pagination-link"')
254+
expect(result).toInclude("2</span>")
255+
expect(result).notToInclude('<li class="page-item')
256+
})
257+
258+
it("preserves default (plain) behavior when viewStyle is plain", () => {
259+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
260+
resultDefault = _controller.pageNumberLinks()
261+
resultPlain = _controller.pageNumberLinks(viewStyle = "plain")
262+
expect(resultPlain).toBe(resultDefault)
263+
})
264+
265+
it("preserves default (plain) behavior — active class stays on anchor, not <li>", () => {
266+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
267+
result = _controller.pageNumberLinks(classForCurrent = "active")
268+
expect(result).notToInclude('<li class="page-item active">')
269+
})
270+
271+
})
272+
273+
/* ── paginationNav viewStyle presets ───────── */
274+
275+
describe("paginationNav with viewStyle presets", () => {
276+
277+
it("wraps Bootstrap 5 markup in <ul class='pagination'> inside <nav>", () => {
278+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
279+
result = _controller.paginationNav(viewStyle = "bootstrap5")
280+
expect(result).toInclude('<nav')
281+
expect(result).toInclude('<ul class="pagination">')
282+
expect(result).toInclude('</ul>')
283+
expect(result).toInclude('</nav>')
284+
expect(result).toInclude('<li class="page-item active" aria-current="page">')
285+
expect(result).toInclude('<span class="page-link">2</span>')
286+
})
287+
288+
it("wraps first/previous/next/last in <li class='page-item'> for Bootstrap 5", () => {
289+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
290+
result = _controller.paginationNav(viewStyle = "bootstrap5")
291+
expect(result).toInclude('<li class="page-item">')
292+
expect(result).toInclude('First')
293+
expect(result).toInclude('Previous')
294+
expect(result).toInclude('Next')
295+
expect(result).toInclude('Last')
296+
})
297+
298+
it("marks first/previous as disabled <li> when on first page in Bootstrap 5", () => {
299+
g.model("author").findAll(page = 1, perPage = 3, order = "lastName")
300+
result = _controller.paginationNav(viewStyle = "bootstrap5")
301+
expect(result).toInclude('<li class="page-item disabled">')
302+
})
303+
304+
it("wraps Bootstrap 4 markup in <ul class='pagination'> without aria-current on current page", () => {
305+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
306+
result = _controller.paginationNav(viewStyle = "bootstrap4")
307+
expect(result).toInclude('<nav')
308+
expect(result).toInclude('<ul class="pagination">')
309+
expect(result).toInclude('<li class="page-item active">')
310+
expect(result).toInclude('<span class="page-link">2</span>')
311+
// BS4 omits aria-current on the active page
312+
expect(result).notToInclude('aria-current="page"')
313+
})
314+
315+
it("marks first/previous as disabled <li> when on first page in Bootstrap 4", () => {
316+
g.model("author").findAll(page = 1, perPage = 3, order = "lastName")
317+
result = _controller.paginationNav(viewStyle = "bootstrap4")
318+
expect(result).toInclude('<li class="page-item disabled">')
319+
})
320+
321+
it("wraps Tailwind markup in a flat <nav class='pagination'> with no <ul>", () => {
322+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
323+
result = _controller.paginationNav(viewStyle = "tailwind")
324+
expect(result).toInclude('<nav aria-label="Pagination" class="pagination">')
325+
expect(result).toInclude('<span class="pagination-current" aria-current="page">')
326+
expect(result).toInclude('class="pagination-link"')
327+
expect(result).notToInclude('<ul')
328+
expect(result).notToInclude('<li class="page-item')
329+
})
330+
331+
it("emits Tailwind pagination-disabled span for first/previous when on first page", () => {
332+
g.model("author").findAll(page = 1, perPage = 3, order = "lastName")
333+
result = _controller.paginationNav(viewStyle = "tailwind")
334+
expect(result).toInclude('<span class="pagination-disabled">')
335+
expect(result).toInclude('First')
336+
expect(result).toInclude('Previous')
337+
})
338+
339+
it("places paginationInfo between <nav> and <ul> for Bootstrap 5 with showInfo=true", () => {
340+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
341+
result = _controller.paginationNav(viewStyle = "bootstrap5", showInfo = true)
342+
expect(result).toInclude('<nav aria-label="Pagination">')
343+
expect(result).toInclude('Showing')
344+
expect(result).toInclude('<ul class="pagination">')
345+
// Info text must appear before the <ul>
346+
infoPos = FindNoCase("Showing", result)
347+
ulPos = FindNoCase("<ul", result)
348+
expect(infoPos).toBeGT(0)
349+
expect(ulPos).toBeGT(0)
350+
expect(infoPos).toBeLT(ulPos)
351+
})
352+
353+
it("throws Wheels.InvalidViewStyle on typo passed to pageNumberLinks", () => {
354+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
355+
expect(function() {
356+
_controller.pageNumberLinks(viewStyle = "boostrap5")
357+
}).toThrow("Wheels.InvalidViewStyle")
358+
})
359+
360+
it("throws Wheels.InvalidViewStyle on typo passed to paginationNav", () => {
361+
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
362+
expect(function() {
363+
_controller.paginationNav(viewStyle = "boostrap5")
364+
}).toThrow("Wheels.InvalidViewStyle")
365+
})
366+
367+
})
368+
228369
/* ── paginationNav ─────────────────────────── */
229370

230371
describe("paginationNav", () => {

0 commit comments

Comments
 (0)