<%= 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 @@
-
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 %>
+
+ <%= t("settings.providers.empty_filter") %>
+
+
+
+ <% @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