Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -22,6 +22,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo

### Added

- `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)
- 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)
- 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)
- `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)
Expand Down
18 changes: 16 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,19 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
#paginationNav()#
#paginationNav(showInfo=true, showFirst=false, showLast=false, navClass="my-pagination")#

// Bootstrap 5 — like-for-like swap for legacy paginationLinks() Bootstrap markup
// Bootstrap 5 — declarative preset: active class on <li>, current page as <span class="page-link">, aria-current
#paginationNav(viewStyle="bootstrap5")#
#pageNumberLinks(viewStyle="bootstrap5")#

// Bootstrap 4 — declarative preset: same as bootstrap5 but omits aria-current
#paginationNav(viewStyle="bootstrap4")#
#pageNumberLinks(viewStyle="bootstrap4")#

// Tailwind — declarative preset: flat structure with pagination-current / pagination-link utility classes
#paginationNav(viewStyle="tailwind")#
#pageNumberLinks(viewStyle="tailwind")#

// Bootstrap 5 — manual composition (granular alternative to viewStyle preset, like-for-like swap for legacy paginationLinks())
#paginationNav(
navClass="",
prepend='<ul class="pagination">',
Expand All @@ -492,7 +504,9 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
#pageNumberLinks(windowSize=5, classForCurrent="active", addActiveClassToPrependedParent=true)#
```

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

Expand Down
2 changes: 2 additions & 0 deletions vendor/wheels/events/init/functions.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@
appendToPage = "",
addActiveClassToPrependedParent = false,
pageNumberAsParam = true,
viewStyle = "plain",
encode = true
};
application.$wheels.functions.paginationNav = {
Expand All @@ -318,6 +319,7 @@
showNext = true,
showInfo = false,
showSinglePage = false,
viewStyle = "plain",
prepend = "",
append = "",
prependToPage = "",
Expand Down
141 changes: 141 additions & 0 deletions vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,147 @@ component extends="wheels.WheelsTest" {

})

/* ── pageNumberLinks viewStyle presets ─────── */

describe("pageNumberLinks with viewStyle presets", () => {

it("emits Bootstrap 5 markup with active class on <li> wrapper and <span> for current page", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
result = _controller.pageNumberLinks(viewStyle = "bootstrap5")
expect(result).toInclude('<li class="page-item active" aria-current="page">')
expect(result).toInclude('<span class="page-link">2</span>')
expect(result).toInclude('<li class="page-item">')
expect(result).toInclude('class="page-link"')
})

it("emits Bootstrap 4 markup with active class on <li> wrapper but no aria-current", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
result = _controller.pageNumberLinks(viewStyle = "bootstrap4")
expect(result).toInclude('<li class="page-item active">')
expect(result).notToInclude('aria-current')
expect(result).toInclude('<span class="page-link">2</span>')
})

it("emits Tailwind markup with pagination-current/pagination-link wrappers", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
result = _controller.pageNumberLinks(viewStyle = "tailwind")
expect(result).toInclude('<span class="pagination-current" aria-current="page">')
expect(result).toInclude('class="pagination-link"')
expect(result).toInclude("2</span>")
expect(result).notToInclude('<li class="page-item')
})

it("preserves default (plain) behavior when viewStyle is plain", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
resultDefault = _controller.pageNumberLinks()
resultPlain = _controller.pageNumberLinks(viewStyle = "plain")
expect(resultPlain).toBe(resultDefault)
})

it("preserves default (plain) behavior — active class stays on anchor, not <li>", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
result = _controller.pageNumberLinks(classForCurrent = "active")
expect(result).notToInclude('<li class="page-item active">')
})

})

/* ── paginationNav viewStyle presets ───────── */

describe("paginationNav with viewStyle presets", () => {

it("wraps Bootstrap 5 markup in <ul class='pagination'> inside <nav>", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
result = _controller.paginationNav(viewStyle = "bootstrap5")
expect(result).toInclude('<nav')
expect(result).toInclude('<ul class="pagination">')
expect(result).toInclude('</ul>')
expect(result).toInclude('</nav>')
expect(result).toInclude('<li class="page-item active" aria-current="page">')
expect(result).toInclude('<span class="page-link">2</span>')
})

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")
expect(result).toInclude('<li class="page-item">')
expect(result).toInclude('First')
expect(result).toInclude('Previous')
expect(result).toInclude('Next')
expect(result).toInclude('Last')
})

it("marks first/previous as disabled <li> when on first page in Bootstrap 5", () => {
g.model("author").findAll(page = 1, perPage = 3, order = "lastName")
result = _controller.paginationNav(viewStyle = "bootstrap5")
expect(result).toInclude('<li class="page-item disabled">')
})

it("wraps Bootstrap 4 markup in <ul class='pagination'> without aria-current on current page", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
result = _controller.paginationNav(viewStyle = "bootstrap4")
expect(result).toInclude('<nav')
expect(result).toInclude('<ul class="pagination">')
expect(result).toInclude('<li class="page-item active">')
expect(result).toInclude('<span class="page-link">2</span>')
// BS4 omits aria-current on the active page
expect(result).notToInclude('aria-current="page"')
})

it("marks first/previous as disabled <li> when on first page in Bootstrap 4", () => {
g.model("author").findAll(page = 1, perPage = 3, order = "lastName")
result = _controller.paginationNav(viewStyle = "bootstrap4")
expect(result).toInclude('<li class="page-item disabled">')
})

it("wraps Tailwind markup in a flat <nav class='pagination'> with no <ul>", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
result = _controller.paginationNav(viewStyle = "tailwind")
expect(result).toInclude('<nav aria-label="Pagination" class="pagination">')
expect(result).toInclude('<span class="pagination-current" aria-current="page">')
expect(result).toInclude('class="pagination-link"')
expect(result).notToInclude('<ul')
expect(result).notToInclude('<li class="page-item')
})

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")
expect(result).toInclude('<span class="pagination-disabled">')
expect(result).toInclude('First')
expect(result).toInclude('Previous')
})

it("places paginationInfo between <nav> and <ul> for Bootstrap 5 with showInfo=true", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
result = _controller.paginationNav(viewStyle = "bootstrap5", showInfo = true)
expect(result).toInclude('<nav aria-label="Pagination">')
expect(result).toInclude('Showing')
expect(result).toInclude('<ul class="pagination">')
// Info text must appear before the <ul>
infoPos = FindNoCase("Showing", result)
ulPos = FindNoCase("<ul", result)
expect(infoPos).toBeGT(0)
expect(ulPos).toBeGT(0)
expect(infoPos).toBeLT(ulPos)
})

it("throws Wheels.InvalidViewStyle on typo passed to pageNumberLinks", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
expect(function() {
_controller.pageNumberLinks(viewStyle = "boostrap5")
}).toThrow("Wheels.InvalidViewStyle")
})

it("throws Wheels.InvalidViewStyle on typo passed to paginationNav", () => {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
expect(function() {
_controller.paginationNav(viewStyle = "boostrap5")
}).toThrow("Wheels.InvalidViewStyle")
})

})

/* ── paginationNav ─────────────────────────── */

describe("paginationNav", () => {
Expand Down
Loading
Loading