diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 7c6428376..ae9d6d014 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -119,19 +119,32 @@ class Settings::ProvidersController < ApplicationController end end + # Hardcoded family-scoped panels — provider connections are managed through + # their own models (SimplefinItem, LunchflowItem, etc.) rather than global + # settings, so they need custom UI per-provider for connection management, + # status display, and sync actions. The configuration registry excludes + # them (see prepare_show_context). + FAMILY_PANELS = [ + { key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" }, + { key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" }, + { key: "enable_banking", title: "Enable Banking (beta)", turbo_id: "enable_banking", partial: "enable_banking_panel" }, + { key: "coinstats", title: "CoinStats (beta)", turbo_id: "coinstats", partial: "coinstats_panel" }, + { key: "mercury", title: "Mercury (beta)", turbo_id: "mercury", partial: "mercury_panel" }, + { key: "coinbase", title: "Coinbase (beta)", turbo_id: "coinbase", partial: "coinbase_panel" }, + { key: "binance", title: "Binance (beta)", turbo_id: "binance", partial: "binance_panel" }, + { key: "snaptrade", title: "SnapTrade (beta)", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" }, + { key: "indexa_capital", title: "Indexa Capital (alpha)", turbo_id: "indexa_capital", partial: "indexa_capital_panel" }, + { key: "sophtron", title: "Sophtron (alpha)", turbo_id: "sophtron", partial: "sophtron_panel" } + ].freeze + + FAMILY_PANEL_KEYS = FAMILY_PANELS.map { |p| p[:key] }.freeze + # Prepares instance vars needed by the show view and partials def prepare_show_context - # Load all provider configurations (exclude SimpleFin and Lunchflow, which have their own family-specific panels below) + # Load all provider configurations (exclude family-scoped panels, which have their own UI below) Provider::Factory.ensure_adapters_loaded @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| - config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \ - config.provider_key.to_s.casecmp("enable_banking").zero? || \ - config.provider_key.to_s.casecmp("sophtron").zero? || \ - config.provider_key.to_s.casecmp("coinstats").zero? || \ - config.provider_key.to_s.casecmp("mercury").zero? || \ - config.provider_key.to_s.casecmp("coinbase").zero? || \ - config.provider_key.to_s.casecmp("snaptrade").zero? || \ - config.provider_key.to_s.casecmp("indexa_capital").zero? + FAMILY_PANEL_KEYS.any? { |key| config.provider_key.to_s.casecmp(key).zero? } end # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @@ -145,5 +158,36 @@ class Settings::ProvidersController < ApplicationController @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered @indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id) + + entries = build_provider_entries + @connected_providers, @available_providers = entries.partition { |entry| entry[:summary][:status] == :ok } + end + + # Builds a unified list of provider entries (registry-driven configurations + # and hardcoded family panels) with pre-computed status, sorted + # alphabetically by display title. Each entry carries enough data for the + # view to render either a provider_form or a family panel partial. + def build_provider_entries + configuration_entries = @provider_configurations.map do |config| + { + provider_key: config.provider_key.to_s, + title: config.provider_key.to_s.titleize, + configuration: config, + summary: view_context.provider_summary(config.provider_key) + } + end + + family_entries = FAMILY_PANELS.map do |panel| + { + provider_key: panel[:key], + title: panel[:title], + turbo_id: panel[:turbo_id], + partial: panel[:partial], + auto_open_param: panel[:auto_open], + summary: view_context.provider_summary(panel[:key]) + } + end + + (configuration_entries + family_entries).sort_by { |entry| entry[:title].downcase } end end diff --git a/app/views/settings/providers/_group_heading.html.erb b/app/views/settings/providers/_group_heading.html.erb new file mode 100644 index 000000000..42eb69764 --- /dev/null +++ b/app/views/settings/providers/_group_heading.html.erb @@ -0,0 +1,12 @@ +<%# locals: (title:, count: nil, description: nil) %> +
+

+ <%= title %> + <% if count %> + · <%= count %> + <% end %> +

+ <% if description.present? %> +

<%= description %>

+ <% end %> +
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index fd7aebba2..44cdf04ed 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -20,47 +20,51 @@ <% end %> <% unless @encryption_error %> - <% @provider_configurations.each do |config| %> - <% summary = provider_summary(config.provider_key) %> - <%= settings_section title: config.provider_key.titleize, collapsible: true, open: false, status: summary[:status], meta: summary[:meta] do %> - <%= render "settings/providers/provider_form", configuration: config %> + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.connected"), + count: @connected_providers.size %> + + <% if @connected_providers.empty? %> +

<%= t("settings.providers.groups.empty_connected") %>

+ <% else %> + <%# Auto-open the only-one connected case so the relevant section is immediately visible. %> + <% auto_open_only = @connected_providers.size == 1 %> + <% @connected_providers.each do |entry| %> + <%= settings_section title: entry[:title], + collapsible: true, + open: auto_open_only, + auto_open_param: entry[:auto_open_param], + status: entry[:summary][:status], + meta: entry[:summary][:meta] do %> + <% if entry[:configuration] %> + <%= render "settings/providers/provider_form", configuration: entry[:configuration] %> + <% else %> + + <%= render "settings/providers/#{entry[:partial]}" %> + + <% end %> + <% end %> <% end %> <% end %> - <%# Providers below are hardcoded because they manage Family-scoped connections %> - <%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %> - <%# They require custom UI for connection management, status display, and sync actions. %> - <%# The controller excludes them from @provider_configurations (see prepare_show_context). %> + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.available"), + count: @available_providers.size %> - <% - family_panels = [ - { key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" }, - { key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" }, - { key: "enable_banking", title: "Enable Banking (beta)", turbo_id: "enable_banking", partial: "enable_banking_panel" }, - { key: "coinstats", title: "CoinStats (beta)", turbo_id: "coinstats", partial: "coinstats_panel" }, - { key: "mercury", title: "Mercury (beta)", turbo_id: "mercury", partial: "mercury_panel" }, - { key: "coinbase", title: "Coinbase (beta)", turbo_id: "coinbase", partial: "coinbase_panel" }, - { key: "binance", title: "Binance (beta)", turbo_id: "binance", partial: "binance_panel" }, - { key: "snaptrade", title: "SnapTrade (beta)", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" }, - { key: "indexa_capital", title: "Indexa Capital (alpha)", turbo_id: "indexa_capital", partial: "indexa_capital_panel" }, - { key: "sophtron", title: "Sophtron (alpha)", turbo_id: "sophtron", partial: "sophtron_panel" }, - ] - - status_order = { ok: 0, warn: 1, err: 2, off: 3 } - sorted_panels = family_panels.sort_by { |p| status_order[provider_summary(p[:key])[:status]] || 3 } - %> - - <% sorted_panels.each do |panel| %> - <% summary = provider_summary(panel[:key]) %> - <%= settings_section title: panel[:title], + <% @available_providers.each do |entry| %> + <%= settings_section title: entry[:title], collapsible: true, open: false, - auto_open_param: panel[:auto_open], - status: summary[:status], - meta: summary[:meta] do %> - - <%= render "settings/providers/#{panel[:partial]}" %> - + auto_open_param: entry[:auto_open_param], + status: entry[:summary][:status], + meta: entry[:summary][:meta] do %> + <% if entry[:configuration] %> + <%= render "settings/providers/provider_form", configuration: entry[:configuration] %> + <% else %> + + <%= render "settings/providers/#{entry[:partial]}" %> + + <% end %> <% end %> <% end %> <% end %> diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 200b07338..92922d2d6 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -191,6 +191,10 @@ en: warn: Action needed err: Error off: Not configured + groups: + connected: Connected + available: Available + empty_connected: Nothing connected yet — pick a provider below to get started. show: coinbase_title: Coinbase encryption_error: diff --git a/test/system/settings/providers_test.rb b/test/system/settings/providers_test.rb index ffaf4a479..645c00ee1 100644 --- a/test/system/settings/providers_test.rb +++ b/test/system/settings/providers_test.rb @@ -48,4 +48,24 @@ class Settings::ProvidersTest < ApplicationSystemTestCase assert details[:open], "Section should open when clicked" details.assert_text "Setup Token" end + + test "groups providers into Connected and Available with counts" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + connected_heading = find("h2", text: /\AConnected/) + assert_match(/· 1\z/, connected_heading.text) + + available_heading = find("h2", text: /\AAvailable/) + + connected_y = connected_heading.native.location.y + available_y = available_heading.native.location.y + simplefin_y = find("details", text: "SimpleFIN").native.location.y + binance_y = find("details", text: "Binance").native.location.y + + assert connected_y < simplefin_y, "Connected heading should appear above SimpleFIN section" + assert simplefin_y < available_y, "SimpleFIN should appear above Available heading" + assert available_y < binance_y, "Available heading should appear above Binance section" + end end