mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04:56 +00:00
refactor: rename beta features gate to preview features (#1837)
* refactor: rename beta features gate to preview features Renames the opt-in gate introduced in PR #1829 from "beta" to "preview". Same shape (per-user JSONB toggle, `before_action` concern, marker pill) just retitled so the surface speaks the language Sure uses elsewhere ("preview" reads as in-progress, "beta" had baggage with provider maturity copy and external testing programs). Renames: - BetaGateable -> PreviewGateable - require_beta_features! -> require_preview_features! - beta_features_enabled? -> preview_features_enabled? - preferences["beta_features_enabled"] -> preferences["preview_features_enabled"] - DS::Pill default label "Beta" -> "Preview" - Settings -> Preferences toggle copy "beta features" -> "preview features" - config/locales/views/beta/ -> config/locales/views/preview/ - docs/llm-guides/gating-a-beta-feature.md -> gating-a-preview-feature.md Includes a data migration that copies any existing `beta_features_enabled` JSONB key into `preview_features_enabled` so early opt-ins survive the rename, then removes the old key. The migration is fully reversible. Provider maturity copy ("maturity.beta = Beta" under Settings -> Bank sync) is intentionally untouched - that's a separate concept describing a provider's integration stability, not Sure's feature gate. * review: apply CodeRabbit findings on PR #1837 - Settings::PreferencesController#update now routes the `preview_features_enabled` input through strong params and casts via ActiveModel::Type::Boolean instead of reading raw params and string- comparing to "1". Matches Sure's controller convention for permitted params and avoids stringly-typed boolean handling. - Rename migration now wraps the destination JSONB key write in COALESCE so a row that somehow ends up with both keys keeps the destination value instead of having it overwritten by the source. Up and down paths get the same defensive shape. * 📝 CodeRabbit Chat: Implement requested code changes * 📝 CodeRabbit Chat: Implement requested code changes * fix: restore all missing translation keys; rename beta→preview label * fix: restore all missing sections (appearances, debugs, llm_usages, providers, etc.); rename beta→preview * fix: restore missing keys (member_removal_failed, confirm_delete, etc.); add preview section * fix(i18n/ca): use 'està en vista prèvia' instead of 'és una vista prèvia' * fix(i18n/ca): use 'en desenvolupament'; drop article in preview title * fix(i18n/es): use 'en desarrollo' instead of 'en progreso' * fix(i18n/ca): use 'funcions experimentals' instead of 'vista prèvia' * fix(i18n/es): use 'funciones experimentales' instead of 'vista previa' * fix(i18n/ca): use 'funcions experimentals' in preferences.show.preview * fix(i18n/es): use 'funciones experimentales' in preferences.show.preview * fix(i18n/ca): use 'Experimental' pill label instead of 'Vista prèvia' * fix(i18n/es): use 'Experimental' pill label instead of 'Vista previa' --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
65bed0db2f
commit
e8ce28648d
@@ -2,7 +2,7 @@ class ApplicationController < ActionController::Base
|
||||
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
|
||||
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
|
||||
FeatureGuardable, Notifiable, SafePagination, AccountAuthorizable,
|
||||
BetaGateable
|
||||
PreviewGateable
|
||||
include Pundit::Authorization
|
||||
|
||||
include Pagy::Backend
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
module BetaGateable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
helper_method :beta_features_enabled?
|
||||
end
|
||||
|
||||
def beta_features_enabled?
|
||||
Current.user&.beta_features_enabled? == true
|
||||
end
|
||||
|
||||
# Use as a `before_action` on controllers that gate a beta feature.
|
||||
# Redirects non-beta users to the dashboard with a flash explaining the
|
||||
# feature is opt-in. Self-served via Settings → Preferences.
|
||||
def require_beta_features!
|
||||
return if beta_features_enabled?
|
||||
|
||||
redirect_to root_path, alert: I18n.t("beta.not_enabled")
|
||||
end
|
||||
end
|
||||
20
app/controllers/concerns/preview_gateable.rb
Normal file
20
app/controllers/concerns/preview_gateable.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module PreviewGateable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
helper_method :preview_features_enabled?
|
||||
end
|
||||
|
||||
def preview_features_enabled?
|
||||
Current.user&.preview_features_enabled? == true
|
||||
end
|
||||
|
||||
# Use as a `before_action` on controllers that gate a preview feature.
|
||||
# Redirects users without preview access to the dashboard with a flash
|
||||
# explaining the feature is opt-in. Self-served via Settings → Preferences.
|
||||
def require_preview_features!
|
||||
return if preview_features_enabled?
|
||||
|
||||
redirect_to root_path, alert: I18n.t("preview.not_enabled")
|
||||
end
|
||||
end
|
||||
@@ -11,11 +11,14 @@ class Settings::PreferencesController < ApplicationController
|
||||
# UsersController#update flow (which expects a full user form payload).
|
||||
def update
|
||||
@user = Current.user
|
||||
user_params = params.permit(user: [ :preview_features_enabled ]).fetch(:user, {})
|
||||
|
||||
@user.transaction do
|
||||
@user.lock!
|
||||
updated_prefs = (@user.preferences || {}).deep_dup
|
||||
if params.dig(:user, :beta_features_enabled)
|
||||
updated_prefs["beta_features_enabled"] = params.dig(:user, :beta_features_enabled) == "1"
|
||||
if user_params.key?(:preview_features_enabled)
|
||||
updated_prefs["preview_features_enabled"] =
|
||||
ActiveModel::Type::Boolean.new.cast(user_params[:preview_features_enabled])
|
||||
end
|
||||
@user.update!(preferences: updated_prefs)
|
||||
end
|
||||
|
||||
@@ -365,8 +365,8 @@ class User < ApplicationRecord
|
||||
preferences&.dig("dashboard_two_column") == true
|
||||
end
|
||||
|
||||
def beta_features_enabled?
|
||||
preferences&.dig("beta_features_enabled") == true
|
||||
def preview_features_enabled?
|
||||
preferences&.dig("preview_features_enabled") == true
|
||||
end
|
||||
|
||||
def update_transactions_preferences(prefs)
|
||||
|
||||
@@ -207,26 +207,26 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%# Beta features toggle — visible to all users, not just admins. Lives at the
|
||||
bottom of Preferences as a standalone card (no section header) so the toggle
|
||||
row is the entire surface. Posts directly to settings#preferences#update via
|
||||
the Settings::PreferencesController, matching the auto-submit pattern used
|
||||
on the Appearance page. %>
|
||||
<%# Preview features toggle — visible to all users, not just admins. Lives at
|
||||
the bottom of Preferences as a standalone card (no section header) so the
|
||||
toggle row is the entire surface. Posts directly to
|
||||
settings#preferences#update via the Settings::PreferencesController,
|
||||
matching the auto-submit pattern used on the Appearance page. %>
|
||||
<section class="bg-container shadow-border-xs rounded-xl p-4">
|
||||
<%= form_with url: settings_preferences_path, method: :patch,
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%# Wrapping the row in <label for=…> makes the title + description
|
||||
themselves a click target for the toggle, so the cursor-pointer
|
||||
affordance on the container is honest. %>
|
||||
<label for="user_beta_features_enabled" class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<label for="user_preview_features_enabled" class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".beta.title") %></h4>
|
||||
<p class="text-secondary"><%= t(".beta.description") %></p>
|
||||
<h4 class="text-primary"><%= t(".preview.title") %></h4>
|
||||
<p class="text-secondary"><%= t(".preview.description") %></p>
|
||||
</div>
|
||||
<%= render DS::Toggle.new(
|
||||
id: "user_beta_features_enabled",
|
||||
name: "user[beta_features_enabled]",
|
||||
checked: @user.beta_features_enabled?,
|
||||
id: "user_preview_features_enabled",
|
||||
name: "user[preview_features_enabled]",
|
||||
checked: @user.preview_features_enabled?,
|
||||
data: { auto_submit_form_target: "auto" }
|
||||
) %>
|
||||
</label>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
en:
|
||||
beta:
|
||||
not_enabled: This feature is in beta. Enable beta features in Settings → Preferences to try it.
|
||||
@@ -92,7 +92,7 @@ ca:
|
||||
close: Tanca
|
||||
pill:
|
||||
aria_label: "%{label}"
|
||||
default_label: Beta
|
||||
default_label: Experimental
|
||||
provider_sync_summary:
|
||||
accounts:
|
||||
institutions: 'Institucions: %{count}'
|
||||
|
||||
@@ -86,7 +86,7 @@ en:
|
||||
destructive: Error
|
||||
pill:
|
||||
aria_label: "%{label}"
|
||||
default_label: Beta
|
||||
default_label: Preview
|
||||
dialog:
|
||||
close: Close
|
||||
provider_sync_summary:
|
||||
|
||||
@@ -63,4 +63,8 @@ es:
|
||||
other: "%{count} transacciones"
|
||||
data_warnings: "Avisos de datos: %{count}"
|
||||
notices: "Avisos: %{count}"
|
||||
view_data_quality: Ver detalles de calidad de datos
|
||||
view_data_quality: Ver detalles de calidad de datos
|
||||
ds:
|
||||
pill:
|
||||
aria_label: "%{label}"
|
||||
default_label: Experimental
|
||||
4
config/locales/views/preview/ca.yml
Normal file
4
config/locales/views/preview/ca.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
ca:
|
||||
preview:
|
||||
not_enabled: Aquesta funció és experimental. Activa les funcions experimentals a Configuració → Preferències per provar-la.
|
||||
4
config/locales/views/preview/en.yml
Normal file
4
config/locales/views/preview/en.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
en:
|
||||
preview:
|
||||
not_enabled: This feature is in preview. Enable preview features in Settings → Preferences to try it.
|
||||
4
config/locales/views/preview/es.yml
Normal file
4
config/locales/views/preview/es.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
es:
|
||||
preview:
|
||||
not_enabled: Esta función es experimental. Activa las funciones experimentales en Configuración → Preferencias para probarla.
|
||||
@@ -129,10 +129,9 @@ ca:
|
||||
additional_currencies_label: Divises addicionals
|
||||
base_currency_badge: Divisa base
|
||||
base_currency_label: Divisa base
|
||||
beta:
|
||||
description: Activa funcions en desenvolupament etiquetades com a beta o
|
||||
canary.
|
||||
title: Activa funcions beta
|
||||
preview:
|
||||
description: Activa funcions en desenvolupament etiquetades com a experimentals o canary.
|
||||
title: Activa funcions experimentals
|
||||
country: País
|
||||
currencies_more: "+%{count} més"
|
||||
currencies_subtitle: Tria quines divises apareixen als camps de diners
|
||||
|
||||
@@ -154,9 +154,9 @@ en:
|
||||
sharing_default_label: Default sharing for new accounts
|
||||
sharing_shared: Share with all members
|
||||
sharing_private: Keep private by default
|
||||
beta:
|
||||
title: Enable beta features
|
||||
description: Opt in to in-progress features tagged beta or canary.
|
||||
preview:
|
||||
title: Enable preview features
|
||||
description: Opt in to in-progress features tagged preview or canary.
|
||||
profiles:
|
||||
destroy:
|
||||
cannot_remove_self: You cannot remove yourself from the account.
|
||||
|
||||
@@ -47,6 +47,9 @@ es:
|
||||
month_start_day: El mes de presupuesto comienza el
|
||||
month_start_day_hint: Establece cuándo empieza tu mes financiero (ej. el día de cobro)
|
||||
month_start_day_warning: Tus presupuestos y cálculos del mes en curso utilizarán este día personalizado en lugar del día 1 de cada mes.
|
||||
preview:
|
||||
title: Habilitar funciones experimentales
|
||||
description: Activa las funciones en desarrollo etiquetadas como experimentales o canary.
|
||||
profiles:
|
||||
destroy:
|
||||
cannot_remove_self: No puedes eliminarte a ti mismo de la cuenta.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
class RenameBetaFeaturesEnabledPreference < ActiveRecord::Migration[7.2]
|
||||
# Renames the JSONB preference key `beta_features_enabled` to
|
||||
# `preview_features_enabled` for every user that has the old key set.
|
||||
# The gate was introduced in PR #1829 and never moved past the Goals
|
||||
# rollout, so opt-in counts are small — but copying the value across keeps
|
||||
# any early adopters opted in after the rename.
|
||||
def up
|
||||
execute(<<~SQL)
|
||||
UPDATE users
|
||||
SET preferences = (preferences - 'beta_features_enabled')
|
||||
|| jsonb_build_object(
|
||||
'preview_features_enabled',
|
||||
COALESCE(preferences->'preview_features_enabled', preferences->'beta_features_enabled')
|
||||
)
|
||||
WHERE preferences ? 'beta_features_enabled'
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
execute(<<~SQL)
|
||||
UPDATE users
|
||||
SET preferences = (preferences - 'preview_features_enabled')
|
||||
|| jsonb_build_object(
|
||||
'beta_features_enabled',
|
||||
COALESCE(preferences->'beta_features_enabled', preferences->'preview_features_enabled')
|
||||
)
|
||||
WHERE preferences ? 'preview_features_enabled'
|
||||
SQL
|
||||
end
|
||||
end
|
||||
2
db/schema.rb
generated
2
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_05_17_122500) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_05_19_092118) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# Gating a beta feature
|
||||
|
||||
Sure ships beta 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["beta_features_enabled"]`, a key inside the existing JSONB column. It defaults to `false`. Reading it goes through `User#beta_features_enabled?`.
|
||||
|
||||
`ApplicationController` includes the `BetaGateable` concern, which exposes two methods to every controller:
|
||||
|
||||
- `beta_features_enabled?`. Returns a boolean. `false` for logged-out callers.
|
||||
- `require_beta_features!`. A `before_action` helper. Redirects non-beta users to `/` with a flash that points them at Settings → Preferences.
|
||||
|
||||
The concern also registers `beta_features_enabled?` as a helper method, so views can call it directly.
|
||||
|
||||
Key files:
|
||||
|
||||
- `app/controllers/concerns/beta_gateable.rb`. The concern.
|
||||
- `app/models/user.rb`. The `beta_features_enabled?` predicate.
|
||||
- `app/views/settings/preferences/show.html.erb`. The toggle UI users see.
|
||||
- `app/components/DS/pill.rb`. The `Beta` / `Canary` marker pill.
|
||||
- `config/locales/views/beta/en.yml`. The redirect flash copy.
|
||||
|
||||
## Gating a controller
|
||||
|
||||
Add `require_beta_features!` as a `before_action`. That's it.
|
||||
|
||||
```ruby
|
||||
class GoalsController < ApplicationController
|
||||
before_action :require_beta_features!
|
||||
end
|
||||
```
|
||||
|
||||
Routes stay defined; the gate runs per-request. Non-beta users hitting `/goals` get redirected with a flash. Beta users pass through.
|
||||
|
||||
If only some actions are gated, scope the `before_action`:
|
||||
|
||||
```ruby
|
||||
class TransactionsController < ApplicationController
|
||||
before_action :require_beta_features!, only: %i[forecast scenarios]
|
||||
end
|
||||
```
|
||||
|
||||
## Gating a view
|
||||
|
||||
Wrap the relevant fragment in the helper:
|
||||
|
||||
```erb
|
||||
<% if beta_features_enabled? %>
|
||||
<li>
|
||||
<%= link_to t(".nav.goals"), goals_path %>
|
||||
</li>
|
||||
<% end %>
|
||||
```
|
||||
|
||||
Same pattern works for dashboard widgets, scoreboard cards, anything that surfaces beta data alongside non-beta data. The helper resolves on every request and reflects the current user's preference.
|
||||
|
||||
## Marking the feature in the UI
|
||||
|
||||
When a beta 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: "Beta", size: :md) %>
|
||||
|
||||
<%# Next to a sidebar nav label or section title. sm is the default. %>
|
||||
<%= render DS::Pill.new(label: "Beta") %>
|
||||
|
||||
<%# 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: "Beta") %>
|
||||
```
|
||||
|
||||
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 beta access" do
|
||||
@user.update!(preferences: (@user.preferences || {}).merge("beta_features_enabled" => false))
|
||||
|
||||
get goals_url
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_match(/beta/i, flash[:alert])
|
||||
end
|
||||
|
||||
test "renders for users with beta access" do
|
||||
@user.update!(preferences: (@user.preferences || {}).merge("beta_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 beta to general availability, removing the gate is a small mechanical PR:
|
||||
|
||||
1. Drop the `before_action :require_beta_features!` line from the controller.
|
||||
2. Unwrap the `if beta_features_enabled?` blocks in views.
|
||||
3. Drop the `DS::Pill` markers from headers, nav, and section titles.
|
||||
4. Delete the controller / view tests that exercise the redirect.
|
||||
|
||||
Grep for `require_beta_features!` and `beta_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 beta off. The UI just disappears from their view while still showing up for opted-in family members.
|
||||
|
||||
The gate does nothing for background jobs. If your feature has a Sidekiq cron job, it runs regardless of who has beta enabled. That's usually correct (data should keep flowing), but if the job sends notifications or emails, gate those at the send site too.
|
||||
|
||||
The redirect target is `/`. If you want gated controllers to land somewhere else (a docs page, an opt-in nudge), override `require_beta_features!` in the controller, or write a thin custom `before_action` that calls `beta_features_enabled?` directly.
|
||||
128
docs/llm-guides/gating-a-preview-feature.md
Normal file
128
docs/llm-guides/gating-a-preview-feature.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# 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? %>
|
||||
<li>
|
||||
<%= link_to t(".nav.goals"), goals_path %>
|
||||
</li>
|
||||
<% 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.
|
||||
|
||||
## 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, nav, and section titles.
|
||||
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. If your feature has a Sidekiq cron job, it runs regardless of who has preview enabled. That's usually correct (data should keep flowing), but if the job sends notifications or emails, gate those at the send site too.
|
||||
|
||||
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.
|
||||
@@ -5,7 +5,7 @@ class PillComponentPreview < ViewComponent::Preview
|
||||
# @param label text
|
||||
# @param show_dot toggle
|
||||
# @param dot_only toggle
|
||||
def default(tone: "violet", style: "soft", size: "sm", label: "Beta", show_dot: true, dot_only: false)
|
||||
def default(tone: "violet", style: "soft", size: "sm", label: "Preview", show_dot: true, dot_only: false)
|
||||
render DS::Pill.new(
|
||||
label: label,
|
||||
tone: tone.to_sym,
|
||||
|
||||
@@ -22,32 +22,32 @@ class Settings::PreferencesControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_select "select[name='user[family_attributes][currency]']", count: 0
|
||||
end
|
||||
|
||||
test "renders beta features toggle for non-admin users too" do
|
||||
test "renders preview features toggle for non-admin users too" do
|
||||
sign_in users(:family_member)
|
||||
get settings_preferences_url
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "Enable beta features"
|
||||
assert_includes response.body, "Enable preview features"
|
||||
end
|
||||
|
||||
test "update toggles beta_features_enabled on" do
|
||||
test "update toggles preview_features_enabled on" do
|
||||
user = users(:family_admin)
|
||||
assert_not user.beta_features_enabled?
|
||||
assert_not user.preview_features_enabled?
|
||||
|
||||
patch settings_preferences_url, params: { user: { beta_features_enabled: "1" } }
|
||||
patch settings_preferences_url, params: { user: { preview_features_enabled: "1" } }
|
||||
|
||||
assert_redirected_to settings_preferences_url
|
||||
assert user.reload.beta_features_enabled?
|
||||
assert user.reload.preview_features_enabled?
|
||||
end
|
||||
|
||||
test "update toggles beta_features_enabled off" do
|
||||
test "update toggles preview_features_enabled off" do
|
||||
user = users(:family_admin)
|
||||
user.update!(preferences: (user.preferences || {}).merge("beta_features_enabled" => true))
|
||||
assert user.beta_features_enabled?
|
||||
user.update!(preferences: (user.preferences || {}).merge("preview_features_enabled" => true))
|
||||
assert user.preview_features_enabled?
|
||||
|
||||
patch settings_preferences_url, params: { user: { beta_features_enabled: "0" } }
|
||||
patch settings_preferences_url, params: { user: { preview_features_enabled: "0" } }
|
||||
|
||||
assert_redirected_to settings_preferences_url
|
||||
assert_not user.reload.beta_features_enabled?
|
||||
assert_not user.reload.preview_features_enabled?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -660,21 +660,21 @@ class UserTest < ActiveSupport::TestCase
|
||||
assert_equal "custom_role", User.role_for_new_family_creator(fallback_role: "custom_role")
|
||||
end
|
||||
|
||||
# Beta features preference tests
|
||||
test "beta_features_enabled? defaults to false" do
|
||||
# Preview features preference tests
|
||||
test "preview_features_enabled? defaults to false" do
|
||||
@user.update!(preferences: {})
|
||||
assert_not @user.beta_features_enabled?
|
||||
assert_not @user.preview_features_enabled?
|
||||
end
|
||||
|
||||
test "beta_features_enabled? true only when explicitly true" do
|
||||
@user.update!(preferences: { "beta_features_enabled" => true })
|
||||
assert @user.beta_features_enabled?
|
||||
test "preview_features_enabled? true only when explicitly true" do
|
||||
@user.update!(preferences: { "preview_features_enabled" => true })
|
||||
assert @user.preview_features_enabled?
|
||||
|
||||
@user.update!(preferences: { "beta_features_enabled" => false })
|
||||
assert_not @user.beta_features_enabled?
|
||||
@user.update!(preferences: { "preview_features_enabled" => false })
|
||||
assert_not @user.preview_features_enabled?
|
||||
|
||||
@user.update!(preferences: { "beta_features_enabled" => "yes" })
|
||||
assert_not @user.beta_features_enabled?, "truthy non-boolean should not enable"
|
||||
@user.update!(preferences: { "preview_features_enabled" => "yes" })
|
||||
assert_not @user.preview_features_enabled?, "truthy non-boolean should not enable"
|
||||
end
|
||||
|
||||
# ActiveStorage attachment cleanup tests
|
||||
|
||||
Reference in New Issue
Block a user