diff --git a/app/components/settings/provider_card.html.erb b/app/components/settings/provider_card.html.erb index d607dd1ca..b3ba5c29c 100644 --- a/app/components/settings/provider_card.html.erb +++ b/app/components/settings/provider_card.html.erb @@ -1,6 +1,6 @@ <%= link_to connect_path, class: "bg-container shadow-border-xs rounded-xl p-4 flex flex-col gap-3 text-primary hover:bg-container-hover transition-colors", - data: { turbo_frame: "drawer", turbo_prefetch: "false" } do %> + data: { turbo_frame: "drawer", turbo_prefetch: "false" }.merge(filter_data) do %>
<%= logo_text %> diff --git a/app/components/settings/provider_card.rb b/app/components/settings/provider_card.rb index 1898352d1..8f3deb253 100644 --- a/app/components/settings/provider_card.rb +++ b/app/components/settings/provider_card.rb @@ -30,6 +30,15 @@ class Settings::ProviderCard < ApplicationComponent helpers.connect_form_settings_providers_path(provider_key: @provider_key) end + def filter_data + { + providers_filter_target: "card", + provider_name: @name.to_s.downcase, + provider_region: @region.to_s.downcase, + provider_kind: @kind.to_s.downcase + } + end + private attr_reader :provider_key, :name, :tagline, :region, :kind, :tier, :maturity, :logo_bg, :logo_text end diff --git a/app/javascript/controllers/providers_filter_controller.js b/app/javascript/controllers/providers_filter_controller.js new file mode 100644 index 000000000..e7134de8c --- /dev/null +++ b/app/javascript/controllers/providers_filter_controller.js @@ -0,0 +1,54 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="providers-filter" +// Filters provider cards by free-text query and a chip-selected kind. +export default class extends Controller { + static targets = ["input", "chip", "card", "empty"]; + static values = { kind: { type: String, default: "all" } }; + + connect() { + this.syncChipState(); + } + + filter() { + const query = this.hasInputTarget + ? this.inputTarget.value.toLocaleLowerCase().trim() + : ""; + const activeKind = this.kindValue; + let visibleCount = 0; + + this.cardTargets.forEach((card) => { + const name = card.dataset.providerName ?? ""; + const region = card.dataset.providerRegion ?? ""; + const kind = card.dataset.providerKind ?? ""; + const haystack = `${name} ${region} ${kind}`; + const matchesQuery = !query || haystack.includes(query); + const matchesKind = activeKind === "all" || kind === activeKind; + const visible = matchesQuery && matchesKind; + card.classList.toggle("hidden", !visible); + if (visible) visibleCount++; + }); + + if (this.hasEmptyTarget) { + this.emptyTarget.classList.toggle("hidden", visibleCount > 0); + } + } + + selectChip(event) { + this.kindValue = event.currentTarget.dataset.kind ?? "all"; + this.syncChipState(); + this.filter(); + } + + syncChipState() { + if (!this.hasChipTarget) return; + this.chipTargets.forEach((chip) => { + const active = chip.dataset.kind === this.kindValue; + chip.classList.toggle("bg-container", active); + chip.classList.toggle("shadow-border-xs", active); + chip.classList.toggle("text-primary", active); + chip.classList.toggle("text-secondary", !active); + chip.setAttribute("aria-pressed", active ? "true" : "false"); + }); + } +} diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb index e2326990d..d891ef4c1 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -10,7 +10,7 @@ class Provider }, lunchflow: { region: "US", - kind: "Lunch", + kind: "Bank", maturity: :stable, logo_bg: "bg-orange-500", logo_text: "LF" diff --git a/app/views/settings/providers/_add_provider_cta.html.erb b/app/views/settings/providers/_add_provider_cta.html.erb deleted file mode 100644 index 4eef9dcac..000000000 --- a/app/views/settings/providers/_add_provider_cta.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -
-
-

<%= t("settings.providers.add_provider_cta.title") %>

-

<%= t("settings.providers.add_provider_cta.body") %>

-
- - <%= icon "plus", class: "w-4 h-4" %> - <%= t("settings.providers.add_provider_cta.cta") %> - -
diff --git a/app/views/settings/providers/_search_filters.html.erb b/app/views/settings/providers/_search_filters.html.erb new file mode 100644 index 000000000..57c94efdf --- /dev/null +++ b/app/views/settings/providers/_search_filters.html.erb @@ -0,0 +1,25 @@ +<%# locals: (count:) %> +
+
+ <%= icon "search", class: "w-4 h-4 text-secondary shrink-0" %> + " + placeholder="<%= t("settings.providers.search_filters.placeholder", count: count) %>" + class="bg-transparent border-0 outline-none flex-1 text-sm text-primary placeholder:text-subdued"> +
+
+ <% %w[all bank crypto investment].each do |kind| %> + <% active = kind == "all" %> + + <% end %> +
+
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 31f41a974..31bf23253 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -60,34 +60,42 @@ <% end %> <% end %> - <% unless @available.empty? %> - <%= render "settings/providers/add_provider_cta" %> - <% end %> + <% if @available.any? %> +
+ <%= render "settings/providers/search_filters", count: @available.size %> - <%= render "settings/providers/group_heading", - title: t("settings.providers.groups.available"), - count: @available.size, - anchor: "available" %> + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.available"), + count: @available.size, + anchor: "available" %> - <% if @available.empty? %> -

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

- <% else %> -
- <% @available.each do |entry| %> - <% meta = Provider::Metadata.for(entry[:provider_key]) %> - <%= render Settings::ProviderCard.new( - provider_key: entry[:provider_key], - name: entry[:title], - tagline: t("settings.providers.taglines.#{entry[:provider_key]}", default: nil), - region: meta[:region], - kind: meta[:kind], - tier: meta[:tier], - maturity: meta[:maturity] || :stable, - logo_bg: meta[:logo_bg], - logo_text: meta[:logo_text] - ) %> - <% end %> + + +
+ <% @available.each do |entry| %> + <% meta = Provider::Metadata.for(entry[:provider_key]) %> + <%= render Settings::ProviderCard.new( + provider_key: entry[:provider_key], + name: entry[:title], + tagline: t("settings.providers.taglines.#{entry[:provider_key]}", default: nil), + region: meta[:region], + kind: meta[:kind], + tier: meta[:tier], + maturity: meta[:maturity] || :stable, + logo_bg: meta[:logo_bg], + logo_text: meta[:logo_text] + ) %> + <% end %> +
+ <% else %> + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.available"), + count: 0, + anchor: "available" %> +

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

<% end %> <% end %>
diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index bf511edaa..aa619e307 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -232,10 +232,17 @@ en: indexa_capital: Track your Indexa Capital automated investment portfolio. sophtron: Connect US & Canadian banks and utilities. plaid: Connect thousands of US financial institutions via Plaid. - add_provider_cta: - title: Add another provider - body: Connect a new bank or data source to start syncing accounts. - cta: Browse providers + search_filters: + aria_label: Search providers + placeholder: + one: "Search %{count} provider" + other: "Search %{count} providers" + chips: + all: All + bank: Banks + crypto: Crypto + investment: Investment + empty_filter: No providers match your filter. show: coinbase_title: Coinbase encryption_error: diff --git a/test/system/settings/providers_test.rb b/test/system/settings/providers_test.rb index fd5870e6b..76148e12d 100644 --- a/test/system/settings/providers_test.rb +++ b/test/system/settings/providers_test.rb @@ -115,18 +115,22 @@ class Settings::ProvidersTest < ApplicationSystemTestCase assert_text "Re-consent needed in 5 days" end - test "add provider CTA banner appears above available group when providers are connected" do - SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") - + test "search input filters provider cards by name" do visit settings_providers_path - cta = find("a", text: "Browse providers") - available_heading = find(:xpath, "//h2[contains(., 'Available')]") + find('[data-providers-filter-target="input"]').set("Coinbase") - cta_y = cta.native.location.y - available_y = available_heading.native.location.y + assert_selector "a[data-providers-filter-target='card']", text: /Coinbase/i + assert_no_selector "a[data-providers-filter-target='card']", text: /Binance/i + end - assert_operator cta_y, :<, available_y, "Add-provider CTA should appear above the Available heading" + test "kind chip narrows the grid to providers of that kind" do + visit settings_providers_path + + click_on "Crypto" + + assert_selector "a[data-providers-filter-target='card']", text: /Coinbase/i + assert_no_selector "a[data-providers-filter-target='card']", text: /SimpleFIN/i end test "available providers render as a card grid" do