Add family moniker selection and dynamic UI labels (#981)

* Add family moniker selection and dynamic UI labels

Introduce a Family moniker persisted in the database with allowed values Family/Group, add required onboarding selection for it, and thread moniker-aware copy through key user-facing views and locales. Also add helper methods and tests for onboarding form presence and family moniker behavior.

* Small copy edits/change moniker question order

* Conditional Group/Family onboarding flow fixes

* Fix label

* Grouping of fields

* Profile Info page Group/Family changes

* Only admins can change Group/Family moniker

* Repetitive defaults

* Moniker in Account model

* Moniker in User model

* Auth fix

* Sure product is also a moniker

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Juan José Mata
2026-02-13 19:30:29 +01:00
committed by GitHub
parent d9acf19038
commit 868a0ae4d8
26 changed files with 180 additions and 29 deletions

View File

@@ -12,6 +12,7 @@ class UsersController < ApplicationController
def update
@user = Current.user
return if moniker_change_requested? && !ensure_admin
if email_changed?
if @user.initiate_email_change(user_params[:email])
@@ -106,7 +107,7 @@ class UsersController < ApplicationController
params.require(:user).permit(
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
:show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale,
family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ],
family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :moniker, :id ],
goals: []
)
end
@@ -115,7 +116,17 @@ class UsersController < ApplicationController
@user = Current.user
end
def moniker_change_requested?
requested_moniker = params.dig(:user, :family_attributes, :moniker)
return false if requested_moniker.blank?
requested_moniker != Current.family.moniker
end
def ensure_admin
redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") unless Current.user.admin?
return true if Current.user.admin?
redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized")
false
end
end

View File

@@ -70,6 +70,23 @@ module ApplicationHelper
end
end
def family_moniker
Current.family&.moniker_label || "Family"
end
def family_moniker_downcase
family_moniker.downcase
end
def family_moniker_plural
Current.family&.moniker_label_plural || "Families"
end
def family_moniker_plural_downcase
family_moniker_plural.downcase
end
def format_money(number_or_money, options = {})
return nil unless number_or_money

View File

@@ -2,6 +2,18 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="onboarding"
export default class extends Controller {
static targets = ["nameField", "monikerRadio"]
static values = {
householdNameLabel: String,
householdNamePlaceholder: String,
groupNameLabel: String,
groupNamePlaceholder: String
}
connect() {
this.updateNameFieldForCurrentMoniker();
}
setLocale(event) {
this.refreshWithParam("locale", event.target.value);
}
@@ -18,6 +30,30 @@ export default class extends Controller {
document.documentElement.setAttribute("data-theme", event.target.value);
}
updateNameFieldForCurrentMoniker(event = null) {
if (!this.hasNameFieldTarget) {
return;
}
const selectedMonikerRadio = event?.target?.dataset?.onboardingMoniker ? event.target : this.monikerRadioTargets.find((radio) => radio.checked);
const selectedMoniker = selectedMonikerRadio?.dataset?.onboardingMoniker;
const isGroup = selectedMoniker === "Group";
this.nameFieldTarget.placeholder = isGroup ? this.groupNamePlaceholderValue : this.householdNamePlaceholderValue;
const label = this.nameFieldTarget.closest(".form-field")?.querySelector(".form-field__label");
if (!label) {
return;
}
if (isGroup) {
label.textContent = this.groupNameLabelValue;
return;
}
label.textContent = this.householdNameLabelValue;
}
refreshWithParam(key, value) {
const url = new URL(window.location);
url.searchParams.set(key, value);

View File

@@ -74,6 +74,11 @@ class Account < ApplicationRecord
end
class << self
def human_attribute_name(attribute, options = {})
options = { moniker: Current.family&.moniker_label || "Family" }.merge(options)
super(attribute, options)
end
def create_and_sync(attributes, skip_initial_sync: false)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
# Default cash_balance to balance unless explicitly provided (e.g., Crypto sets it to 0)

View File

@@ -17,6 +17,9 @@ class Family < ApplicationRecord
[ "YYYYMMDD", "%Y%m%d" ]
].freeze
MONIKERS = [ "Family", "Group" ].freeze
has_many :users, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :invitations, dependent: :destroy
@@ -43,6 +46,16 @@ class Family < ApplicationRecord
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
validates :month_start_day, inclusion: { in: 1..28 }
validates :moniker, inclusion: { in: MONIKERS }
def moniker_label
moniker.presence || "Family"
end
def moniker_label_plural
moniker_label == "Group" ? "Groups" : "Families"
end
def uses_custom_month_start?
month_start_day != 1

View File

@@ -22,7 +22,7 @@
<p class="text-sm font-medium"><%= t("entries.protection.locked_fields_label") %></p>
<% entry.locked_fields_with_timestamps.each do |field, timestamp| %>
<div class="flex items-center justify-between text-xs gap-2 mb-1">
<span><%= entry.class.human_attribute_name(field) %></span>
<span><%= entry.class.human_attribute_name(field, moniker: Current.family&.moniker_label || "Family") %></span>
<hr class="grow border-dashed border-secondary">
<span><%= timestamp.respond_to?(:strftime) ? l(timestamp.to_date, format: :long) : timestamp %></span>
</div>

View File

@@ -17,7 +17,7 @@
<div class="bg-container rounded-xl shadow-border-xs p-4 space-y-6">
<section class="space-y-3">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
<p><%= t(".family_title") %></p>
<p><%= t(".family_title", moniker: family_moniker) %></p>
<span class="text-subdued">&middot;</span>
<p><%= @family_merchants.count %></p>
</div>
@@ -41,7 +41,7 @@
<% else %>
<div class="flex justify-center items-center py-12">
<div class="text-center flex flex-col items-center max-w-[300px]">
<p class="text-primary mb-1 font-medium text-sm"><%= t(".family_empty") %></p>
<p class="text-primary mb-1 font-medium text-sm"><%= t(".family_empty", moniker: family_moniker_downcase) %></p>
<%= render DS::Link.new(
text: t(".new"),
@@ -87,7 +87,7 @@
</div>
<% else %>
<div class="flex justify-center items-center py-8">
<p class="text-secondary text-sm text-center"><%= t(".provider_empty") %></p>
<p class="text-secondary text-sm text-center"><%= t(".provider_empty", moniker: family_moniker_downcase) %></p>
</div>
<% end %>
</section>

View File

@@ -5,6 +5,7 @@
".body",
inviter: @invitation.inviter.display_name,
family: @invitation.family.name,
moniker: @invitation.family.moniker_label.downcase,
product_name: product_name
).html_safe %>
</p>

View File

@@ -1,5 +1,5 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title"), subtitle: t(".subtitle", product_name: product_name)) %>
<% dialog.with_header(title: t(".title"), subtitle: t(".subtitle", product_name: product_name, moniker: family_moniker_downcase)) %>
<% dialog.with_body do %>
<%= styled_form_with model: @invitation, class: "space-y-4", data: { turbo: false } do |form| %>

View File

@@ -10,14 +10,14 @@
<%= render "onboardings/logout" %>
<% end %>
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0">
<div class="grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-2 px-4 md:px-0">
<div>
<div class="space-y-1 mb-6 text-center">
<h1 class="text-2xl font-medium md:text-2xl"><%= t(".title") %></h1>
<p class="text-secondary text-sm"><%= t(".subtitle") %></p>
</div>
<%= styled_form_with model: @user do |form| %>
<%= styled_form_with model: @user do |form| %>
<%= form.hidden_field :redirect_to, value: @invitation ? "home" : "onboarding_preferences" %>
<%= form.hidden_field :onboarded_at, value: Time.current if @invitation %>
@@ -33,7 +33,37 @@
<% unless @invitation %>
<div class="space-y-4 mb-4">
<%= form.fields_for :family do |family_form| %>
<%= family_form.text_field :name, placeholder: t(".household_name_placeholder"), label: t(".household_name") %>
<div class="bg-container rounded-lg shadow-border-xs p-4">
<div class="space-y-2"
data-controller="onboarding"
data-onboarding-household-name-label-value="<%= t(".household_name") %>"
data-onboarding-household-name-placeholder-value="<%= t(".household_name_placeholder") %>"
data-onboarding-group-name-label-value="<%= t(".group_name") %>"
data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>"
>
<p class="text-sm font-medium text-primary"><%= t(".moniker_prompt", product_name: product_name) %></p>
<label class="flex items-center gap-2 text-sm text-primary">
<%= family_form.radio_button :moniker, "Family", required: true, data: {
action: "onboarding#updateNameFieldForCurrentMoniker",
onboarding_target: "monikerRadio",
onboarding_moniker: "Family"
} %>
<span><%= t(".moniker_family") %></span>
</label>
<label class="flex items-center gap-2 text-sm text-primary">
<%= family_form.radio_button :moniker, "Group", required: true, data: {
action: "onboarding#updateNameFieldForCurrentMoniker",
onboarding_target: "monikerRadio",
onboarding_moniker: "Group"
} %>
<span><%= t(".moniker_group") %></span>
</label>
<%= family_form.text_field :name, placeholder: t(".household_name_placeholder"), label: t(".household_name"), data: { onboarding_target: "nameField" } %>
</div>
</div>
<%= family_form.select :country,
country_options,

View File

@@ -1,5 +1,5 @@
<%
header_title @invitation ? t(".join_family_title", family: @invitation.family.name) : t(".title")
header_title @invitation ? t(".join_family_title", family: @invitation.family.name, moniker: @invitation.family.moniker_label) : t(".title")
%>
<% if self_hosted_first_login? %>

View File

@@ -26,13 +26,15 @@
<% end %>
<% unless Current.user.ui_layout_intro? %>
<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %>
<%= settings_section title: family_moniker == "Group" ? t(".group_title", default: "Group") : t(".household_title"), subtitle: t(".household_subtitle", moniker_plural: family_moniker_plural_downcase, moniker: family_moniker_downcase) do %>
<div class="space-y-4">
<%= styled_form_with model: Current.user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.fields_for :family do |family_fields| %>
<% name_label = family_moniker == "Group" ? t(".group_form_label", default: "Group name") : t(".household_form_label") %>
<% name_placeholder = family_moniker == "Group" ? t(".group_form_input_placeholder", default: "Enter group name") : t(".household_form_input_placeholder") %>
<%= family_fields.text_field :name,
placeholder: t(".household_form_input_placeholder"),
label: t(".household_form_label"),
placeholder: name_placeholder,
label: name_label,
disabled: !Current.user.admin?,
"data-auto-submit-form-target": "auto" %>
<% end %>

View File

@@ -6,7 +6,7 @@
<% dialog.with_body do %>
<% if @available_simplefin_accounts.blank? %>
<div class="p-4 text-sm text-secondary">
<p class="mb-2"><%= t("simplefin_items.select_existing_account.no_accounts_found") %></p>
<p class="mb-2"><%= t("simplefin_items.select_existing_account.no_accounts_found", moniker: family_moniker_downcase) %></p>
<ul class="list-disc list-inside space-y-1">
<li><%= t("simplefin_items.select_existing_account.wait_for_sync") %></li>
<li><%= t("simplefin_items.select_existing_account.check_provider_health") %></li>

View File

@@ -5,8 +5,8 @@ en:
account:
balance: Balance
currency: Currency
family: Family
family_id: Family
family: "%{moniker}"
family_id: "%{moniker}"
name: Name
subtype: Subtype
models:

View File

@@ -4,8 +4,8 @@ en:
attributes:
user:
email: Email
family: Family
family_id: Family
family: "%{moniker}"
family_id: "%{moniker}"
first_name: First Name
last_name: Last Name
password: Password

View File

@@ -3,6 +3,6 @@ en:
invitation_mailer:
invite_email:
accept_button: Accept Invitation
body: "%{inviter} has invited you to join the %{family} family on %{product_name}!"
body: "%{inviter} has invited you to join the %{family} %{moniker} on %{product_name}!"
expiry_notice: This invitation will expire in %{days} days
greeting: Welcome to %{product_name}!

View File

@@ -23,5 +23,5 @@ en:
role_label: Role
role_member: Member
submit: Send Invitation
subtitle: Send an invitation to join your family account on %{product_name}
subtitle: Send an invitation to join your %{moniker} account on %{product_name}
title: Invite Someone

View File

@@ -18,10 +18,10 @@ en:
new: New merchant
merge: Merge merchants
title: Merchants
family_title: Family merchants
family_empty: No family merchants yet
family_title: "%{moniker} merchants"
family_empty: "No %{moniker} merchants yet"
provider_title: Provider merchants
provider_empty: No provider merchants linked to this family yet
provider_empty: "No provider merchants linked to this %{moniker} yet"
provider_read_only: Provider merchants are synced from your connected institutions. They cannot be edited here.
provider_info: These merchants were automatically detected by your bank connections or AI. You can edit them to create your own copy, or remove them to unlink from your transactions.
unlinked_title: Recently unlinked

View File

@@ -16,8 +16,13 @@ en:
first_name_placeholder: First name
last_name: Last name
last_name_placeholder: Last name
group_name: Group name
group_name_placeholder: Group name
household_name: Household name
household_name_placeholder: Household name
moniker_prompt: "Will be using %{product_name} with ..."
moniker_family: Family members (just yourself or with partner, teens, etc.)
moniker_group: Group of people (company, club, association, any other type)
country: Country
submit: Continue
preferences:
@@ -58,4 +63,4 @@ en:
in_40_days: In 40 days (%{date})
in_40_days_description: We'll notify you to remind you to export your data.
in_45_days: In 45 days (%{date})
in_45_days_description: We delete your data — contribute to continue using Sure here!
in_45_days_description: We delete your data — contribute to continue using Sure here!

View File

@@ -15,7 +15,7 @@ en:
success: You have signed up successfully.
new:
invitation_message: "%{inviter} has invited you to join as a %{role}"
join_family_title: Join %{family}
join_family_title: Join %{family} %{moniker}
role_admin: administrator
role_guest: guest
role_member: member

View File

@@ -80,10 +80,12 @@ en:
reset_account_with_sample_data_warning: Delete all your existing data and then load fresh sample data so you can explore with a pre-filled environment.
email: Email
first_name: First Name
group_form_input_placeholder: Enter group name
group_form_label: Group name
group_title: Group Members
household_form_input_placeholder: Enter household name
household_form_label: Household name
household_subtitle: Invite family members, partners and other inviduals. Invitees
can login to your household and access your shared accounts.
household_subtitle: Invitees can login to your %{moniker} account and access shared resources.
household_title: Household
invitation_link: Invitation link
invite_member: Add member

View File

@@ -87,7 +87,7 @@ en:
description: Select a SimpleFIN account to link to your existing account
cancel: Cancel
link_account: Link account
no_accounts_found: No SimpleFIN accounts found for this family.
no_accounts_found: "No SimpleFIN accounts found for this %{moniker}."
wait_for_sync: If you just connected or synced, try again after the sync completes.
unlink_to_move: To move a link, first unlink it from the accounts actions menu.
all_accounts_already_linked: All SimpleFIN accounts appear to be linked already.

View File

@@ -0,0 +1,5 @@
class AddMonikerToFamilies < ActiveRecord::Migration[7.2]
def change
add_column :families, :moniker, :string, null: false, default: "Family"
end
end

1
db/schema.rb generated
View File

@@ -501,6 +501,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_11_120001) do
t.boolean "recurring_transactions_disabled", default: false, null: false
t.integer "month_start_day", default: 1, null: false
t.string "vector_store_id"
t.string "moniker", default: "Family", null: false
t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range"
end

View File

@@ -17,6 +17,16 @@ class OnboardingsControllerTest < ActionDispatch::IntegrationTest
assert_select "h1", text: /set up your account/i
end
test "onboarding setup includes required moniker selection" do
get onboarding_url
assert_response :success
assert_select "input[name='user[family_attributes][moniker]'][value='Family'][required]"
assert_select "input[name='user[family_attributes][moniker]'][value='Group'][required]"
assert_select "p", text: /Will be using Sure with/i
end
test "should get preferences" do
get preferences_onboarding_url
assert_response :success

View File

@@ -36,6 +36,19 @@ class FamilyTest < ActiveSupport::TestCase
end
end
test "moniker helpers return expected singular and plural labels" do
family = families(:dylan_family)
family.update!(moniker: "Family")
assert_equal "Family", family.moniker_label
assert_equal "Families", family.moniker_label_plural
family.update!(moniker: "Group")
assert_equal "Group", family.moniker_label
assert_equal "Groups", family.moniker_label_plural
end
test "available_merchants includes family merchants without transactions" do
family = families(:dylan_family)