# Gating a preview feature
Sure ships preview features behind a single per-user toggle. Users opt in via Settings → Preferences. Opted-in users see your feature; everyone else doesn't. This guide is for hooking a new feature into the gate.
The intent is to ship in-progress work without blocking smaller PRs on a "feels finished" bar. You gate the entry points (routes, nav, anything that links into your feature) and iterate behind them. Once stable, you remove the gate in a small follow-up PR.
## How the gate works
The state lives on `users.preferences["preview_features_enabled"]`, a key inside the existing JSONB column. It defaults to `false`. Reading it goes through `User#preview_features_enabled?`.
`ApplicationController` includes the `PreviewGateable` concern, which exposes two methods to every controller:
- `preview_features_enabled?`. Returns a boolean. `false` for logged-out callers.
- `require_preview_features!`. A `before_action` helper. Redirects users without preview access to `/` with a flash that points them at Settings → Preferences.
The concern also registers `preview_features_enabled?` as a helper method, so views can call it directly.
Key files:
- `app/controllers/concerns/preview_gateable.rb`. The concern.
- `app/models/user.rb`. The `preview_features_enabled?` predicate.
- `app/views/settings/preferences/show.html.erb`. The toggle UI users see.
- `app/components/DS/pill.rb`. The `Preview` / `Canary` marker pill.
- `config/locales/views/preview/en.yml`. The redirect flash copy.
## Gating a controller
Add `require_preview_features!` as a `before_action`. That's it.
```ruby
class GoalsController < ApplicationController
before_action :require_preview_features!
end
```
Routes stay defined; the gate runs per-request. Users without preview access hitting `/goals` get redirected with a flash. Preview users pass through.
If only some actions are gated, scope the `before_action`:
```ruby
class TransactionsController < ApplicationController
before_action :require_preview_features!, only: %i[forecast scenarios]
end
```
## Gating a view
Wrap the relevant fragment in the helper:
```erb
<% if preview_features_enabled? %>
<%= link_to t(".nav.goals"), goals_path %>
<% end %>
```
Same pattern works for dashboard widgets, scoreboard cards, anything that surfaces preview data alongside non-preview data. The helper resolves on every request and reflects the current user's preference.
## Gating the main nav
The desktop sidebar rail and the mobile bottom nav both render from `app/views/layouts/shared/_nav_item.html.erb`. The partial accepts an optional `preview:` local — when true, it overlays a violet dot-only pill on the icon so opted-in users can tell at a glance that the rail entry leads to a preview surface.
Use the `preview_gated_nav_item` helper to wrap the entry. It returns `nil` for users without preview access (so the entry never enters the nav, once `Array#compact` runs) and stamps `preview: true` for opted-in users (so the partial paints the dot). One call, both halves of the gate:
```erb
<% mobile_nav_items = [
{ name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
{ name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
preview_gated_nav_item({ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }),
{ name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
].compact %>
```
You don't need to touch `_nav_item.html.erb` or set `preview: true` by hand. Adding a new preview nav entry is one helper call wrapped around the same hash you'd write anyway.
## Marking the feature in the UI
When a preview surface renders for an opted-in user, mark it. The pill component lives in the design system:
```erb
<%# Next to a page header. The md size pairs with h1 / h2. %>
<%= render DS::Pill.new(label: "Preview", size: :md) %>
<%# Next to a sidebar nav label or section title. sm is the default. %>
<%= render DS::Pill.new(label: "Preview") %>
<%# Same shape, fuchsia tone, for canary / experimental surfaces. %>
<%= render DS::Pill.new(label: "Canary", tone: :fuchsia) %>
<%# Sidebar icon rail has no room for a label. The dot-only mode keeps the tone semantics without the text. %>
<%= render DS::Pill.new(tone: :violet, dot_only: true, title: "Preview") %>
```
Default tone is violet. Tones available: `violet`, `indigo`, `fuchsia`, `amber`, `gray`. Styles: `soft` (default), `filled`, `outline`. Sizes: `sm` (default), `md`. The Lookbook preview at `/design-system` (look for `PillComponentPreview#default`) flips every option, so you can see what your call site renders without a round trip to Rails.
## Tests
Gated controllers should test both states. The pattern:
```ruby
class GoalsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
end
test "redirects users without preview access" do
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
get goals_url
assert_redirected_to root_path
assert_match(/preview/i, flash[:alert])
end
test "renders for users with preview access" do
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
get goals_url
assert_response :success
end
end
```
If you write a system test, flip the preference in setup the same way before the visit.
## Removing the gate when the feature ships GA
When a feature moves from preview to general availability, removing the gate is a small mechanical PR:
1. Drop the `before_action :require_preview_features!` line from the controller.
2. Unwrap the `if preview_features_enabled?` blocks in views.
3. Drop the `DS::Pill` markers from headers and section titles, and unwrap the `preview_gated_nav_item(...)` call back into a plain nav-item hash.
4. Delete the controller / view tests that exercise the redirect.
Grep for `require_preview_features!` and `preview_features_enabled?` near your feature to confirm nothing's left behind.
## Notes
The flag is per-user, not per-family. Two users in the same family can see different versions of the product if one opts in and the other doesn't. That's intentional. Data is family-scoped, but visibility is a personal preference. If you write a feature that creates family-shared data (goals, budgets, etc.), the data persists when a user toggles preview off. The UI just disappears from their view while still showing up for opted-in family members.
The gate does nothing for background jobs on its own — `PreviewGateable` reads `Current.user`, which a Sidekiq worker doesn't have. A cron job runs regardless of who has preview enabled. That's usually correct: data should keep flowing, so it's there when someone opts in. `SweepExpiredGoalPledgesJob` is the typical shape — it only walks pledges that opted-in users created, so it's naturally inert for everyone else and needs no gate.
Gate the job when it does work that isn't free. Two cases: it sends something outward (notifications, emails — gate at the send site), or it *manufactures* data for every family rather than moving existing data around, burning compute or paid API calls per family. `GenerateInsightsJob` is the second case: seven generators over the income statement and balance sheet, plus optional LLM narration, for every family nightly.
For a family-scoped job, roll the per-user flag up to the family:
```ruby
# app/models/family.rb
scope :with_preview_features, -> { where(id: User.with_preview_features.select(:family_id)) }
def preview_features_enabled?
users.with_preview_features.exists?
end
```
Filter the fan-out with the scope (one indexed query — `User.with_preview_features` is a jsonb containment match against the GIN index on `users.preferences`, not a load-and-iterate), and re-check with the predicate inside the per-family path, which is reachable directly from controllers and the console. Keep the same predicate name on both models so the GA-removal grep finds every call site. `GenerateInsightsJob` is the reference implementation.
One opted-in member is enough to generate for the whole family — consistent with the per-user-visibility / family-scoped-data split described above.
Note what the shared name costs: `preview_features_enabled?` means "I opted in" on `User` but "somebody in my household opted in" on `Family`. Only ever gate background work on the family form. Gating UI on it (`Current.family.preview_features_enabled?` reads naturally, which is the trap) would show the feature to someone who explicitly opted out because a family member opted in. For anything a person sees, use the `PreviewGateable` helper, which reads `Current.user`.
The redirect target is `/`. If you want gated controllers to land somewhere else (a docs page, an opt-in nudge), override `require_preview_features!` in the controller, or write a thin custom `before_action` that calls `preview_features_enabled?` directly.