Skip to content

Commit 0c79f86

Browse files
committed
Merge branch 'develop' into fix/bot-2716-paginationnav-default-anchor-display-ux-differs-fr
Resolves conflicts with #2730 (Bootstrap-style wrappers), #2731 (viewStyle preset), and #2736 (whereIn empty-array fix) — all of which landed on develop while this PR was in review. Conflict resolutions: - CHANGELOG.md: kept both this PR's `Fixed` entry and #2736's QueryBuilder empty-IN fix; they are independent. - CLAUDE.md: composed all three feature sections (auto-mode tri-state, viewStyle presets, manual Bootstrap composition) into one pagination reference block; the `Bootstrap 5 — manual composition` comment now also notes `showFirst="always"` to restore 3.x always-show behaviour. - vendor/wheels/events/init/functions.cfm: added both `windowSize = 2` and `viewStyle = "plain"` defaults to the `paginationNav` function argument map. - vendor/wheels/view/pagination.cfc: added both `windowSize` and `viewStyle` to the docblock, signature, and `skipArgs` list. In the `$renderPaginationNav` early-return path, anchor display flags are now resolved through `$paginationShouldShowAnchor()` so viewStyle presets honour the new tri-state `"auto"` / `"always"` / `"never"` modes consistently with the plain path. - vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc: two viewStyle preset tests that asserted First/Last anchor presence at page=2 (BS5 per-item wrapping) and page=1 (Tailwind disabled-span) now explicitly pass `showFirst="always"` / `showLast="always"` — the new default `"auto"` mode auto-suppresses these when the rendered page-number window already reaches the boundary, which is the intended behaviour of this PR. Verified locally on Lucee 7 + SQLite: view (555/0/0) and model (830/0/0) spec bundles green. Signed-off-by: Peter Amiri <peter@alurium.com>
2 parents dfd2725 + 1588e32 commit 0c79f86

16 files changed

Lines changed: 1005 additions & 17 deletions

CHANGELOG.md

Lines changed: 2 additions & 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)
@@ -36,6 +37,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
3637
### Fixed
3738

3839
- `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
40+
- `QueryBuilder.whereIn()` / `whereNotIn()` with an empty array no longer emit malformed SQL (`property IN ()`). Previously, passing an empty list or array to either method produced syntactically invalid SQL that surfaced as a generic JDBC syntax error from the database, with no pointer back to the call site that built the empty collection. `whereIn(prop, [])` now sets an `$alwaysEmpty` flag on the builder so every terminal method (`count`, `findAll`, `findOne`, `first`, `exists`, `updateAll`, `deleteAll`, `findEach`, `findInBatches`) short-circuits to the appropriate zero-row sentinel before going through the finder. `whereNotIn(prop, [])` is a no-op (exclude-none = match-all), so the chain proceeds normally. Matches the user-facing behaviour every mature ORM converged on (Rails, Sequel, Django, Laravel Eloquent: empty `IN` matches no rows, empty `NOT IN` matches every row). The flag-based design avoids a runtime trap from Wheels' WHERE-clause parser (`vendor/wheels/model/sql.cfc` runs a property-extraction regex over every clause it sees — a raw `1 = 0` literal would be parsed as property `1` and trip `Wheels.ColumnNotFound`). Fourteen new specs in `vendor/wheels/tests/specs/model/queryBuilderSpec.cfc` cover empty-array, empty-list, composition with other clauses, the `whereNotIn` mirrors, every patched terminal (`findAll`, `first` / `findOne`, `exists`, `count`, `updateAll`, `deleteAll`, `findEach`, `findInBatches`), and the documented `select()` / `include()` silent-ignore caveat on the short-circuit path. Both copies of the query-builder guide were updated to document the short-circuit in the methods table (#2736)
3941
- `wheels mcp setup` now writes a stdio-based `.opencode.json` instead of one pointing at the deprecated HTTP MCP endpoint. `cli/src/templates/OpenCodeConfig.json` — the file the setup command actually reads from (`setup.cfc:53`) — still carried the pre-4.0 shape: `"url": "http://localhost:{PORT}/wheels/mcp", "type": "remote"`, with `{PORT}` left as an unsubstituted literal string. OpenCode users running `wheels mcp setup` ended up with a config trying to connect to a host called `{PORT}` against an endpoint that emits a deprecation warning on every call. The template now uses the same stdio form already shipped in `tools/build/base/.opencode.json`: `"type": "local", "command": ["wheels", "mcp", "wheels"]`. The companion monorepo reference copy at `app/snippets/OpenCodeConfig.json` (not read by the setup command, but kept in sync for consistency) was updated to match. The CHANGELOG entry from when the stdio shift originally landed claimed all template copies had been updated; this closes the two that were missed (#2735)
4042
- `wheels packages --help` / `wheels packages help` / `wheels packages -h` now emit a module-owned help string that documents `add` as the canonical install verb and explains why typing `install` does not work (LuCLI's built-in extension installer intercepts the literal verb before dispatch reaches the module — same trap that hit `wheels browser install``wheels browser setup` in #2345). Previously the auto-introspected help drifted from the real CLI surface, advertising an `install <name> [--force]` row that never actually installed anything (#2713)
4143
- Package manifest field reference in `web/sites/guides/.../packages.mdx` (both v4-0-0 and v4-0-1-snapshot copies) and `CLAUDE.md`: the inter-package dependency field is `requires`, not `dependencies`. The legacy 3.x plugin shape used `dependencies` in `box.json`; the modern `PackageLoader` (`vendor/wheels/ModuleGraph.cfc`) has always read `requires`, plus `replaces` (exclusion / migration path) and `suggests` (soft load-order edge). Copying the old example manifest would have shipped a package that loaded but silently ignored its declared dependencies — no error, no warning, just a missing-dep failure at the first runtime call into the absent dependency. All three docs now use `requires` and the previously undocumented `replaces` / `suggests` fields are covered alongside. Same PR also tightens the guide's description of `wheelsVersion` mismatches: not just "logged" but a hard skip — incompatible packages are excluded from the load order before their CFC is instantiated and recorded in `failedPackages` with the constraint and running version named in the log (#2734)

CLAUDE.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,19 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
471471
#paginationNav(showInfo=true, showFirst="never", showLast="never", navClass="my-pagination")#
472472
#paginationNav(windowSize=3)# // widens window used by auto-mode boundary detection
473473
474-
// Bootstrap 5 — like-for-like swap for legacy paginationLinks() Bootstrap markup.
474+
// Bootstrap 5 — declarative preset: active class on <li>, current page as <span class="page-link">, aria-current
475+
#paginationNav(viewStyle="bootstrap5")#
476+
#pageNumberLinks(viewStyle="bootstrap5")#
477+
478+
// Bootstrap 4 — declarative preset: same as bootstrap5 but omits aria-current
479+
#paginationNav(viewStyle="bootstrap4")#
480+
#pageNumberLinks(viewStyle="bootstrap4")#
481+
482+
// Tailwind — declarative preset: flat structure with pagination-current / pagination-link utility classes
483+
#paginationNav(viewStyle="tailwind")#
484+
#pageNumberLinks(viewStyle="tailwind")#
485+
486+
// Bootstrap 5 — manual composition (granular alternative to viewStyle preset, like-for-like swap for legacy paginationLinks()).
475487
// Pass showFirst="always" / showLast="always" to restore the 3.x alwaysShowAnchors=true
476488
// behaviour; omit them to keep the new "auto" boundary-hide default.
477489
#paginationNav(
@@ -495,7 +507,11 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme
495507
#pageNumberLinks(windowSize=5, classForCurrent="active", addActiveClassToPrependedParent=true)#
496508
```
497509

498-
`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. `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 `" "`).
510+
`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"`).
511+
512+
`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.
513+
514+
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.
499515

500516
In development (`showErrorInformation = true`), `paginationNav()` throws `Wheels.PaginationNav.InvalidArgument` if passed an argument not accepted by any of its sub-helpers. `windowSize` is a first-class `paginationNav()` argument (like `navClass` / `showInfo`) and is consumed internally — it is not forwarded to the anchor sub-helpers. Accepted pass-through keys: `format`, `text`, `name`, `class`, `disabledClass`, `showDisabled`, `pageNumberAsParam`, `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.
501517

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Blog Drafts
2+
3+
Unpublished blog posts wait here until they're ready to ship. CI does not pick up files in this folder — `web/content/blog/posts/` is the live source. Move a draft into `web/content/blog/posts/` to publish it (CI will deploy on the next push to develop).
4+
5+
Each draft carries a `publishedAt` date in its frontmatter that's the intended publication day. The deploy is still gated by a human moving the file; the date is what shows on the published article.
6+
7+
## Current queue
8+
9+
Scheduled for every-other-day cadence after the rate-limited API post (published 2026-05-15):
10+
11+
| Draft | Slot |
12+
|---|---|
13+
| `anatomy-of-a-wheels-package.md` | 2026-05-17 |
14+
| `wheels-claude-stdio-mcp.md` | 2026-05-19 |
15+
| `beyond-findall-scopes-enums-query-builder.md` | 2026-05-21 |
16+
17+
The companion social-post skeletons live in `../blog-skeletons/`. When you promote a draft, copy the social skeleton too.
18+
19+
## Publishing checklist
20+
21+
1. Review the draft for any references that need a final pass (cross-links to other posts in the series, date math in teaser lines).
22+
2. `git mv docs/releases/blog-drafts/<post>.md web/content/blog/posts/<post>.md`
23+
3. Commit on a feature branch, open a PR.
24+
4. After the PR merges, the deploy workflow picks up the new file and ships to https://blog.wheels.dev.
25+
5. Post the companion social skeleton(s) on the channels in their checklist.

web/content/blog/posts/anatomy-of-a-wheels-package.md renamed to docs/releases/blog-drafts/anatomy-of-a-wheels-package.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
---
22
title: Anatomy of a Wheels Package
33
slug: anatomy-of-a-wheels-package
4-
publishedAt: '2026-05-22T14:00:00.000Z'
5-
updatedAt: '2026-05-22T14:00:00.000Z'
4+
publishedAt: '2026-05-17T14:00:00.000Z'
5+
updatedAt: '2026-05-17T14:00:00.000Z'
66
author: Peter Amiri
77
tags:
88
- wheels-4
@@ -293,4 +293,4 @@ The second was the `wheelsVersion` constraint. The guide described mismatches as
293293

294294
Neither of these is a code change — both are documentation fixes — but they're the kind of drift that costs an hour the first time you hit it, and they're the reason a piece like this is worth writing. Anything you have to write down to be sure of is something the next person was going to have to figure out from scratch.
295295

296-
The next post in the series — *Wheels + Claude: building a feature via the stdio MCP* — picks up the same theme on a different surface: what the framework's tools look like when the consumer is a model rather than a developer. Coming next week.
296+
The next post in the series — *Wheels + Claude: building a feature via the stdio MCP* — picks up the same theme on a different surface: what the framework's tools look like when the consumer is a model rather than a developer. Coming Tuesday.

0 commit comments

Comments
 (0)