feat(settings/providers): replace Add another provider CTA with a search + kind filter

Per the design review, the "Add another provider · Browse providers"
card was a redirect to content one scroll-tick away. A search input
plus kind chips lets users self-segment the catalog and is the right
tool once it grows beyond the four to twelve providers we ship today.

- New providers_filter Stimulus controller — case-insensitive free
  text search across name/region/kind, plus a chip group with
  All / Banks / Crypto / Investment that toggle visibility via
  Tailwind's `hidden` class.
- _search_filters partial: search box (count-pluralized placeholder)
  + chip group, ARIA-labelled and aria-pressed for the chips.
- ProviderCard exposes filter_data (target + name/region/kind data
  attrs) so the controller can match without re-rendering.
- Lunchflow's `kind` was "Lunch" — switched to "Bank" so it falls
  under the Banks chip alongside its actual offering (it aggregates
  banks).
- Drops the add_provider_cta partial and its locale entries; adds
  search_filters.* and an empty_filter message.
This commit is contained in:
Guillem Arias
2026-05-09 11:33:13 +02:00
committed by Guillem Arias
parent bf73e3a1e3
commit d037412b8d
9 changed files with 146 additions and 49 deletions

View File

@@ -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 %>
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 <%= logo_bg %>">
<span class="text-xs font-bold text-inverse"><%= logo_text %></span>

View File

@@ -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

View File

@@ -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");
});
}
}

View File

@@ -10,7 +10,7 @@ class Provider
},
lunchflow: {
region: "US",
kind: "Lunch",
kind: "Bank",
maturity: :stable,
logo_bg: "bg-orange-500",
logo_text: "LF"

View File

@@ -1,10 +0,0 @@
<div class="bg-container shadow-border-xs rounded-xl p-4 flex items-center justify-between gap-4">
<div>
<p class="font-medium text-primary"><%= t("settings.providers.add_provider_cta.title") %></p>
<p class="text-sm text-secondary mt-0.5"><%= t("settings.providers.add_provider_cta.body") %></p>
</div>
<a href="#available" class="inline-flex items-center gap-2 shrink-0 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary transition-colors">
<%= icon "plus", class: "w-4 h-4" %>
<%= t("settings.providers.add_provider_cta.cta") %>
</a>
</div>

View File

@@ -0,0 +1,25 @@
<%# locals: (count:) %>
<div class="flex flex-wrap items-center gap-2 mt-2">
<div class="flex-1 min-w-[200px] flex items-center gap-2 bg-container shadow-border-xs rounded-lg px-3 py-2">
<%= icon "search", class: "w-4 h-4 text-secondary shrink-0" %>
<input type="search"
data-providers-filter-target="input"
data-action="input->providers-filter#filter"
aria-label="<%= t("settings.providers.search_filters.aria_label") %>"
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">
</div>
<div class="inline-flex items-center gap-1 p-1 bg-surface-inset rounded-lg">
<% %w[all bank crypto investment].each do |kind| %>
<% active = kind == "all" %>
<button type="button"
data-providers-filter-target="chip"
data-action="click->providers-filter#selectChip"
data-kind="<%= kind %>"
aria-pressed="<%= active %>"
class="px-2.5 py-1 text-xs font-medium rounded-md transition-colors <%= active ? "bg-container shadow-border-xs text-primary" : "text-secondary" %>">
<%= t("settings.providers.search_filters.chips.#{kind}") %>
</button>
<% end %>
</div>
</div>

View File

@@ -60,34 +60,42 @@
<% end %>
<% end %>
<% unless @available.empty? %>
<%= render "settings/providers/add_provider_cta" %>
<% end %>
<% if @available.any? %>
<div data-controller="providers-filter">
<%= 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? %>
<p class="text-sm text-secondary px-1 py-2"><%= t("settings.providers.groups.empty_available") %></p>
<% else %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<% @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 %>
<p data-providers-filter-target="empty" class="hidden text-sm text-secondary px-1 py-2">
<%= t("settings.providers.empty_filter") %>
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<% @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 %>
</div>
</div>
<% else %>
<%= render "settings/providers/group_heading",
title: t("settings.providers.groups.available"),
count: 0,
anchor: "available" %>
<p class="text-sm text-secondary px-1 py-2"><%= t("settings.providers.groups.empty_available") %></p>
<% end %>
<% end %>
</div>

View File

@@ -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:

View File

@@ -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