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
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Install the `wheels` CLI. Five minutes.
wheels --version
```

You should see a `Wheels Version: <version>` line followed by ASCII art and a `Lucee Version: <version>` line. Any non-empty version output means the CLI is wired up correctly.
You should see a `Wheels Version: <version>` line followed by ASCII art. A `Lucee Version: <version>` line may also appear once Lucee Express has been downloaded (typically on first `wheels start`). Any non-empty version output means the CLI is wired up correctly.

</Steps>

Expand Down Expand Up @@ -84,7 +84,7 @@ The `wheels` package on the public Chocolatey feed is still the v1.x legacy (Com
wheels --version
```

You should see a `Wheels Version: <version>` line followed by ASCII art and a `Lucee Version: <version>` line. Any non-empty version output means the CLI is wired up correctly.
You should see a `Wheels Version: <version>` line followed by ASCII art. A `Lucee Version: <version>` line may also appear once Lucee Express has been downloaded (typically on first `wheels start`). Any non-empty version output means the CLI is wired up correctly.

</Steps>

Expand Down Expand Up @@ -116,7 +116,7 @@ Pick LuCLI and Wheels Module versions that are known to be paired — see [CLI I
wheels --version
```

You should see a `Wheels Version: <version>` line followed by ASCII art and a `Lucee Version: <version>` line. Any non-empty version output means the CLI is wired up correctly.
You should see a `Wheels Version: <version>` line followed by ASCII art. A `Lucee Version: <version>` line may also appear once Lucee Express has been downloaded (typically on first `wheels start`). Any non-empty version output means the CLI is wired up correctly.

</Steps>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ At the end of Part 1 you had a running `blog` app with a `/hello` route. There's
wheels generate model Post title:string body:text
```

That creates `app/models/Post.cfc` (a near-empty `component extends="Model"`) and a migration file under `app/migrator/migrations/` with a current timestamp.
That creates `app/models/Post.cfc` and a migration file under `app/migrator/migrations/` with a current timestamp. The generated model is short but not empty — it ships with `validatesPresenceOf("title,body")` already populated for the two columns you passed in.

2. Replace the contents of `app/models/Post.cfc` with the version below — it adds the `status` enum we'll use to filter posts:
2. Replace the contents of `app/models/Post.cfc` with the version below — we drop the auto-generated validations for now (Part 4 brings them back) and add the `status` enum we'll use to filter posts:

```cfm {test:compile}
component extends="Model" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ Route model binding kicks in on the actions that take a key. For `show`, `edit`,
Drop the hand-written controller and views. The model, migration, seed file, and the `resources("posts")` route stay.

```bash title="your shell"
wheels destroy Posts controller
wheels destroy Posts controller --force
```

The argument order is `<name> [type]` — `Posts controller`, not `controller Posts`. If you'd rather skip the CLI and remove the files yourself:
The argument order is `<name> [type]` — `Posts controller`, not `controller Posts`. The `--force` flag skips the interactive confirmation; without it, `destroy` prints `Use --force to confirm deletion.` and exits without touching anything. If you'd rather skip the CLI and remove the files yourself:

```bash title="your shell"
rm app/controllers/Posts.cfc
Expand All @@ -96,7 +96,9 @@ wheels generate scaffold Post title:string body:text status:enum
```

<Aside type="caution">
The scaffold generator will refuse to overwrite an existing `app/models/Post.cfc` and bail before producing the controller and views. Since chapter 2 already wrote the model, you'll see `Scaffold failed: Model already exists`. When that happens, hand-write the files in the sections below — they're what the generator would have produced. (A future release will add a `--skip-existing` flag so the scaffold can reuse the existing model.)
The scaffold generator skips files that already exist rather than failing the whole run. Since chapter 2 already wrote `app/models/Post.cfc` and the migration, you'll see `skip model: Model already exists` and `skip migration: create_posts_table already exists` in the output — but the controller, views, and tests will still be generated. The scaffold also appends `.resources("posts")` to `config/routes.cfm`; we'll replace the resulting routes file in a few steps anyway.

The generator's emitted controller uses `findByKey(params.key)` everywhere and emits Bootstrap-flavored views — perfectly fine, but a different shape from this tutorial. Replace the scaffold's output with the code in the sections below to follow along; pass `--force` next time if you want the scaffold to overwrite. Or compare the two — generator vs. tutorial — to see two equally valid CRUD shapes.
</Aside>

### The controller
Expand Down Expand Up @@ -165,18 +167,10 @@ The `new` and `edit` views both render the same form. Put the shared markup in a
<cfoutput>
#errorMessagesFor("post")#
#startFormTag(action=IsNumeric(post.id ?: "") ? "update" : "create", key=post.id ?: "")#
<label>Title<br>
#textField(objectName="post", property="title")#
</label>
<label>Body<br>
#textArea(objectName="post", property="body")#
</label>
<label>Status<br>
#select(objectName="post", property="status", options="draft,published,archived")#
</label>
<label>Published at<br>
#dateField(objectName="post", property="publishedAt")#
</label>
#textField(objectName="post", property="title", label="Title")#
#textArea(objectName="post", property="body", label="Body")#
#select(objectName="post", property="status", options="draft,published,archived", label="Status")#
#dateField(objectName="post", property="publishedAt", label="Published at")#
<button type="submit">Save</button>
#endFormTag()#
</cfoutput>
Expand All @@ -189,7 +183,7 @@ Notes:
- Partial filenames start with `_`. You reference them as `"form"` (no underscore, no extension) when including.
- `errorMessagesFor("post")` renders a `<div class="errors">` with any validation errors on the `post` object. When there are none, it renders nothing.
- `startFormTag` picks the right HTTP method and action URL based on whether `post.id` exists. A new `Post` has no id, so the form posts to `create`; an existing one PATCHes to `update`.
- `textField`, `textArea`, `select`, `dateField` are object-bound helpers — `objectName="post"` plus `property="title"` becomes `name="post[title]"` with the current value pre-filled.
- `textField`, `textArea`, `select`, `dateField` are object-bound helpers — `objectName="post"` plus `property="title"` becomes `name="post[title]"` with the current value pre-filled. Each helper emits its own `<label>` wrapper around the input; pass `label="Title"` to set the visible text. Don't wrap them in another `<label>` — that produces nested `<label>` elements, which browsers handle inconsistently.

### The four views

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ Four pieces to notice:
}

function create() {
user = model("User").findOne(where="email = :email", values={email: LCase(Trim(params.email ?: ""))});
emailVal = LCase(Trim(params.email ?: ""));
user = model("User").findOneByEmail(emailVal);
if (IsObject(user) && user.authenticate(params.password ?: "")) {
session.userId = user.id;
redirectTo(route="posts", success="Welcome back.");
Expand All @@ -224,9 +225,13 @@ Four pieces to notice:
Three actions, three responsibilities:

- `new` renders the login form. No setup needed; the view uses no dynamic data.
- `create` is the login POST. It normalizes the submitted email the same way the model does on save, looks up the user via a parameterized finder (`:email` placeholder plus `values` struct — this is injection-safe; never interpolate user input into a `where` string), and calls `user.authenticate(password)`. Success sets `session.userId` and redirects; failure flashes an error and sends the browser back to the login form.
- `create` is the login POST. It normalizes the submitted email the same way the model does on save, looks up the user via the dynamic finder `findOneByEmail` — Wheels generates `findOneBy<Property>` for every column, and the value is bound as a parameter so this is injection-safe. Then it calls `user.authenticate(password)`. Success sets `session.userId` and redirects; failure flashes an error and sends the browser back to the login form.
- `delete` is the logout action. It clears `session.userId` and redirects.

<Aside type="tip" title="Why a dynamic finder, not a `where=` string">
You'll see two patterns in older code: dynamic finders like `findOneByEmail(value)` and explicit `where=` finders. The dynamic finders parameterize the value automatically — Wheels turns `findOneByEmail(emailVal)` into a prepared statement with a bound parameter. Writing `findOne(where="email = '#emailVal#'")` would interpolate the user input straight into SQL, which is a SQL injection vulnerability. Stick to dynamic finders for single-column lookups; reach for `where=` only when you need an operator other than equality, and always use `cfqueryparam`-style binding via the [chainable query builder](/v4-0-0-snapshot/basics/models-and-the-orm/#chainable-query-builder).
</Aside>

### Protect the Posts controller

<Steps>
Expand Down Expand Up @@ -478,6 +483,12 @@ Then walk through the flow in the browser:

That's the hand-rolled version. Thirty-ish lines of logic total, no hidden machinery. Now let's see what the framework gives you for free.

<Aside type="caution" title="Scripting smoke tests with curl? Read this first.">
**CSRF token rotation**: Wheels rotates the CSRF token after each accepted POST. A scraped token is single-use — re-POSTing with the same token fails with `Wheels.InvalidAuthenticityToken`. To script a flow, fetch a fresh form before every write. The signup form has a hidden `authenticityToken` input you can scrape from the response body; the login form (which uses raw `<input>` tags rather than form helpers) doesn't, so scrape from the layout's `<meta name="csrf-token">` tag instead.

**curl `--data-urlencode` and `@`**: `curl --data-urlencode "user[email]=test@example.com"` correctly URL-encodes the `@` to `%40`. But `curl --data-urlencode "user[email][test@example.com]"` (no `=` separator) gets misread by Lucee's form parser as a nested-bracket key path — `params.user.email` arrives as `{"test@example.com": ""}` instead of the string. The cause is curl + Lucee form encoding, not Wheels — but it surfaces as a confusing `Can't cast Complex Object Type Struct to String` error. Browsers always %-encode `@`, so this is curl-only. Always include the `=` and let `--data-urlencode` encode the value side: `--data-urlencode "user[email]=test@example.com"`.
</Aside>

---

## Part 6b: The Built-in Way
Expand Down Expand Up @@ -530,7 +541,8 @@ On a cold reload this registers the strategy exactly once. The `hasStrategy` che
}

function create() {
user = model("User").findOne(where="email = :email", values={email: LCase(Trim(params.email ?: ""))});
emailVal = LCase(Trim(params.email ?: ""));
user = model("User").findOneByEmail(emailVal);
if (IsObject(user) && user.authenticate(params.password ?: "")) {
var sessionStrategy = application.wo.service("sessionStrategy");
sessionStrategy.login(principal={id: user.id, email: user.email});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,15 @@ Browser specs drive a real Chromium through the rendered HTML. Behind the scenes
Playwright ships as a set of JARs plus a Chromium binary, ~370MB in total. Install it once:

```bash title="your shell"
wheels browser install
wheels browser setup
```

The installer downloads the JARs into `lib/`, Chromium into `~/Library/Caches` (macOS) or `~/.cache` (Linux), and writes a manifest so CI knows what's cached.

<Aside type="note">
The verb is `setup`, not `install`. `wheels browser install` reaches LuCLI's generic extension installer first and prints `No git or extension dependencies to install` — looks like success but does nothing. Always use `setup`.
</Aside>

<Steps>

1. Create `tests/specs/browser/SignupFlowSpec.cfc`:
Expand Down Expand Up @@ -278,14 +282,14 @@ wheels --version
Three things to verify before moving on:

1. `wheels test` passes both the model spec and the controller spec.
2. `wheels browser install` completes without error, and `wheels test --filter=browser` runs the browser spec green — Chromium launches, the signup-to-post-to-comment flow runs end-to-end.
2. `wheels browser setup` completes without error, and `wheels test --filter=browser` runs the browser spec green — Chromium launches, the signup-to-post-to-comment flow runs end-to-end.
3. Click through the running app one more time: sign up, create a post, leave a comment, log out, log back in, try to edit someone else's post and get redirected. Every part of the tutorial still works end-to-end.

## Troubleshooting

**"Model spec fails with `cannot find Post.cfc`."** The test runner boots a fresh schema from `tests/populate.cfm`, which you haven't set up yet. See [Fixtures & Test Data](/v4-0-0-snapshot/testing/fixtures-and-test-data/) for the standard `populate.cfm` that mirrors your development schema into the test database.

**"Browser spec hangs for thirty seconds, then times out."** Playwright isn't installed. Run `wheels browser install` — it downloads ~370MB and prints a progress bar. Once it completes, re-run the spec. If it still hangs, check that `this.browserTestSkipped` isn't incorrectly set; the guard clause should short-circuit when the JARs are missing, not when they're present.
**"Browser spec hangs for thirty seconds, then times out."** Playwright isn't installed. Run `wheels browser setup` — it downloads ~370MB and prints a progress bar. Once it completes, re-run the spec. If it still hangs, check that `this.browserTestSkipped` isn't incorrectly set; the guard clause should short-circuit when the JARs are missing, not when they're present.

**"`expect(...).toBeTrue is not a function`."** The spec CFC extends the wrong base. Use `component extends="wheels.WheelsTest"` — with a capital T in `WheelsTest`. The legacy `wheels.Test` is the RocketUnit base and doesn't have the BDD matchers.

Expand Down
Loading