Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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)
- 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)
- `wheels deploy init` now scaffolds a starter `Dockerfile` (Lucee 7 + Java 21 multi-stage, `/up` HEALTHCHECK aligned with the generated `kamal-proxy` healthcheck) and a `.dockerignore` alongside `config/deploy.yml` and `.kamal/secrets`. `--force` also gates the `Dockerfile` — an existing user-authored Dockerfile aborts the init without `--force`, while an existing `.dockerignore` is silently preserved (since it's commonly user-curated even before adopting `wheels deploy`). The npm builder stage works for any Wheels app — projects without a JS pipeline pass through unchanged; projects with a `package.json` install + build automatically. Secrets (reload password, DB password, registry password) are injected at deploy time via `.kamal/secrets`, never baked into the image (#2673)

Expand Down
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,18 @@ 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 — active class on <li>, current page as <span class="page-link">, aria-current
#paginationNav(viewStyle="bootstrap5")#
#pageNumberLinks(viewStyle="bootstrap5")#

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

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

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

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

## Testing Quick Reference
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 @@ -307,6 +307,7 @@
prependToPage = "",
appendToPage = "",
pageNumberAsParam = true,
viewStyle = "plain",
encode = true
};
application.$wheels.functions.paginationNav = {
Expand All @@ -317,6 +318,7 @@
showNext = true,
showInfo = false,
showSinglePage = false,
viewStyle = "plain",
encode = true
};
application.$wheels.functions.paginationLinks = {
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