mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">·</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 account’s actions menu.
|
||||
all_accounts_already_linked: All SimpleFIN accounts appear to be linked already.
|
||||
|
||||
5
db/migrate/20260211101500_add_moniker_to_families.rb
Normal file
5
db/migrate/20260211101500_add_moniker_to_families.rb
Normal 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
1
db/schema.rb
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user