Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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`, and `class` 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