mirror of
https://github.com/we-promise/sure.git
synced 2026-05-21 19:44:55 +00:00
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:
committed by
Guillem Arias
parent
bf73e3a1e3
commit
d037412b8d
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
54
app/javascript/controllers/providers_filter_controller.js
Normal file
54
app/javascript/controllers/providers_filter_controller.js
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ class Provider
|
||||
},
|
||||
lunchflow: {
|
||||
region: "US",
|
||||
kind: "Lunch",
|
||||
kind: "Bank",
|
||||
maturity: :stable,
|
||||
logo_bg: "bg-orange-500",
|
||||
logo_text: "LF"
|
||||
|
||||
@@ -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>
|
||||
25
app/views/settings/providers/_search_filters.html.erb
Normal file
25
app/views/settings/providers/_search_filters.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user