1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

Custom tabs, convert all menu / tab instances in app

This commit is contained in:
Zach Gollwitzer 2025-04-28 14:33:18 -04:00
parent f6016e47e2
commit 83bd001637
30 changed files with 365 additions and 287 deletions

View file

@ -1,4 +1,4 @@
<%= tag.div data: { controller: "menu", menu_placement_value: placement, menu_offset_value: offset } do %>
<%= tag.div data: { controller: "menu", menu_placement_value: placement, menu_offset_value: offset, testid: testid } do %>
<% if variant == :icon %>
<%= render ButtonComponent.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { menu_target: "button" }) %>
<% elsif variant == :button %>
@ -11,15 +11,17 @@
</button>
<% end %>
<div data-menu-target="content" class="hidden min-w-[200px] z-50 shadow-border-xs bg-container rounded-lg">
<%= header %>
<div data-menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
<%= header %>
<div class="py-1">
<% items.each do |item| %>
<%= item %>
<%= tag.div class: class_names("py-1" => !no_padding) do %>
<% items.each do |item| %>
<%= item %>
<% end %>
<%= custom_content %>
<% end %>
<%= custom_content %>
</div>
</div>
<% end %>

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class MenuComponent < ViewComponent::Base
attr_reader :variant, :avatar_url, :placement, :offset, :icon_vertical
attr_reader :variant, :avatar_url, :placement, :offset, :icon_vertical, :no_padding, :testid
renders_one :button, ->(**button_options, &block) do
options_with_target = button_options.merge(data: { menu_target: "button" })
@ -23,12 +23,14 @@ class MenuComponent < ViewComponent::Base
VARIANTS = %i[icon button avatar].freeze
def initialize(variant: "icon", avatar_url: nil, placement: "bottom-end", offset: 12, icon_vertical: false)
def initialize(variant: "icon", avatar_url: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil)
@variant = variant.to_sym
@avatar_url = avatar_url
@placement = placement
@offset = offset
@icon_vertical = icon_vertical
@no_padding = no_padding
@testid = testid
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
end

View file

@ -0,0 +1,29 @@
class Tabs::NavComponent < ViewComponent::Base
erb_template <<~ERB
<%= tag.nav class: classes do %>
<% btns.each do |btn| %>
<%= btn %>
<% end %>
<% end %>
ERB
renders_many :btns, ->(id:, label:, classes: nil, &block) do
content_tag(
:button, label, id: id,
type: "button",
class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),
data: { id: id, action: "tabs#show", tabs_target: "navBtn" },
&block
)
end
attr_reader :active_tab, :classes, :active_btn_classes, :inactive_btn_classes, :btn_classes
def initialize(active_tab:, classes: nil, active_btn_classes: nil, inactive_btn_classes: nil, btn_classes: nil)
@active_tab = active_tab
@classes = classes
@active_btn_classes = active_btn_classes
@inactive_btn_classes = inactive_btn_classes
@btn_classes = btn_classes
end
end

View file

@ -0,0 +1,11 @@
class Tabs::PanelComponent < ViewComponent::Base
attr_reader :tab_id
def initialize(tab_id:)
@tab_id = tab_id
end
def call
content
end
end

View file

@ -1,22 +1,17 @@
<%= tag.div class: "space-y-4", data: {
<%= tag.div data: {
controller: "tabs",
testid: testid,
tabs_url_param_key_value: url_param_key,
tabs_nav_btn_active_class: nav_btn_active_classes,
tabs_nav_btn_inactive_class: nav_btn_inactive_classes
tabs_nav_btn_active_class: active_btn_classes,
tabs_nav_btn_inactive_class: inactive_btn_classes
} do %>
<nav class="flex bg-surface-inset p-1 rounded-lg">
<% tabs.each do |tab| %>
<%= tag.button class: nav_btn_classes(active: tab.id == active_tab), data: { id: tab.id, action: "tabs#show", tabs_target: "navBtn" } do %>
<%= tab.label %>
<% end %>
<% end %>
</nav>
<% if unstyled? %>
<%= content %>
<% else %>
<%= nav %>
<div>
<% tabs.each do |tab| %>
<%= tag.div class: class_names("hidden" => tab.id != active_tab), data: { id: tab.id, tabs_target: "panel" } do %>
<%= tab %>
<% end %>
<% panels.each do |panel| %>
<%= panel %>
<% end %>
</div>
<% end %>
<% end %>
<% end %>

View file

@ -1,38 +1,65 @@
class TabsComponent < ViewComponent::Base
renders_many :tabs, TabComponent
renders_one :nav, ->(classes: nil) do
Tabs::NavComponent.new(
active_tab: active_tab,
active_btn_classes: active_btn_classes,
inactive_btn_classes: inactive_btn_classes,
btn_classes: base_btn_classes,
classes: unstyled? ? classes : class_names(nav_container_classes, classes)
)
end
renders_many :panels, ->(tab_id:, &block) do
content_tag(
:div,
class: ("hidden" unless tab_id == active_tab),
data: { id: tab_id, tabs_target: "panel" },
&block
)
end
VARIANTS = {
default: {
nav_btn_active_classes: "bg-white theme-dark:bg-gray-700 text-primary shadow-sm",
nav_btn_inactive_classes: "text-secondary hover:bg-surface-inset-hover",
nav_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200"
active_btn_classes: "bg-white theme-dark:bg-gray-700 text-primary shadow-sm",
inactive_btn_classes: "text-secondary hover:bg-surface-inset-hover",
base_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200",
nav_container_classes: "flex bg-surface-inset p-1 rounded-lg mb-4"
}
}
attr_reader :variant, :url_param_key
attr_reader :active_tab, :url_param_key, :variant, :testid
def initialize(active_tab: nil, variant: "default", url_param_key: nil)
def initialize(active_tab:, url_param_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil)
@active_tab = active_tab
@variant = variant.to_sym
@url_param_key = url_param_key
@variant = variant.to_sym
@active_btn_classes = active_btn_classes
@inactive_btn_classes = inactive_btn_classes
@testid = testid
end
def active_tab
@active_tab || tabs.first.id
def active_btn_classes
unstyled? ? @active_btn_classes : VARIANTS.dig(variant, :active_btn_classes)
end
def nav_btn_active_classes
VARIANTS.dig(variant, :nav_btn_active_classes)
def inactive_btn_classes
unstyled? ? @inactive_btn_classes : VARIANTS.dig(variant, :inactive_btn_classes)
end
def nav_btn_inactive_classes
VARIANTS.dig(variant, :nav_btn_inactive_classes)
end
private
def unstyled?
variant == :unstyled
end
def nav_btn_classes(active: false)
class_names(
VARIANTS.dig(variant, :nav_btn_classes),
active ? nav_btn_active_classes : nav_btn_inactive_classes
)
end
def base_btn_classes
unless unstyled?
VARIANTS.dig(variant, :base_btn_classes)
end
end
def nav_container_classes
unless unstyled?
VARIANTS.dig(variant, :nav_container_classes)
end
end
end

View file

@ -0,0 +1,42 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="tabs--components"
export default class extends Controller {
static classes = ["navBtnActive", "navBtnInactive"];
static targets = ["panel", "navBtn"];
static values = { urlParamKey: String };
connect() {
console.log("tabs controller connected");
}
show(e) {
const btn = e.target.closest("button");
const selectedTabId = btn.dataset.id;
this.navBtnTargets.forEach((navBtn) => {
if (navBtn.dataset.id === selectedTabId) {
navBtn.classList.add(...this.navBtnActiveClasses);
navBtn.classList.remove(...this.navBtnInactiveClasses);
} else {
navBtn.classList.add(...this.navBtnInactiveClasses);
navBtn.classList.remove(...this.navBtnActiveClasses);
}
});
this.panelTargets.forEach((panel) => {
if (panel.dataset.id === selectedTabId) {
panel.classList.remove("hidden");
} else {
panel.classList.add("hidden");
}
});
// Update URL with the selected tab
if (this.urlParamKeyValue) {
const url = new URL(window.location.href);
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
window.history.replaceState({}, "", url);
}
}
}

View file

@ -1,5 +1,5 @@
<div class="relative inline-block select-none">
<%= hidden_field_tag name, unchecked_value, id: nil %>
<%= check_box_tag name, checked_value, checked, class: "sr-only peer", disabled: disabled, id: id, **opts %>
<%= check_box_tag name, checked_value, checked, class: "sr-only peer", disabled: disabled, id: id, **opts %>
<%= label_tag name, "&nbsp;".html_safe, class: label_classes, for: id %>
</div>
</div>

View file

@ -1,13 +1,13 @@
module TransactionsHelper
def transaction_search_filters
[
{ key: "account_filter", icon: "layers" },
{ key: "date_filter", icon: "calendar" },
{ key: "type_filter", icon: "tag" },
{ key: "amount_filter", icon: "hash" },
{ key: "category_filter", icon: "shapes" },
{ key: "tag_filter", icon: "tags" },
{ key: "merchant_filter", icon: "store" }
{ key: "account_filter", label: "Account", icon: "layers" },
{ key: "date_filter", label: "Date", icon: "calendar" },
{ key: "type_filter", label: "Type", icon: "tag" },
{ key: "amount_filter", label: "Amount", icon: "hash" },
{ key: "category_filter", label: "Category", icon: "shapes" },
{ key: "tag_filter", label: "Tag", icon: "tags" },
{ key: "merchant_filter", label: "Merchant", icon: "store" }
]
end

View file

@ -1,107 +0,0 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="tabs"
export default class extends Controller {
static classes = ["active", "inactive", "navBtnActive", "navBtnInactive"];
static targets = ["btn", "tab", "panel", "navBtn"];
static values = {
defaultTab: String,
localStorageKey: String,
urlParamKey: String,
};
connect() {
const selectedTab = this.hasLocalStorageKeyValue
? this.getStoredTab() || this.defaultTabValue
: this.defaultTabValue;
this.updateClasses(selectedTab);
document.addEventListener("turbo:load", this.onTurboLoad);
}
disconnect() {
document.removeEventListener("turbo:load", this.onTurboLoad);
}
select(event) {
const element = event.target.closest("[data-id]");
if (element) {
const selectedId = element.dataset.id;
this.updateClasses(selectedId);
if (this.hasLocalStorageKeyValue) {
this.storeTab(selectedId);
}
}
}
show(e) {
const selectedTabId = e.target.dataset.id;
this.navBtnTargets.forEach((navBtn) => {
if (navBtn.dataset.id === selectedTabId) {
navBtn.classList.add(...this.navBtnActiveClasses);
navBtn.classList.remove(...this.navBtnInactiveClasses);
} else {
navBtn.classList.add(...this.navBtnInactiveClasses);
navBtn.classList.remove(...this.navBtnActiveClasses);
}
});
this.panelTargets.forEach((panel) => {
if (panel.dataset.id === selectedTabId) {
panel.classList.remove("hidden");
} else {
panel.classList.add("hidden");
}
});
// Update URL with the selected tab
if (this.urlParamKeyValue) {
const url = new URL(window.location.href);
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
window.history.replaceState({}, "", url);
}
}
onTurboLoad = () => {
const selectedTab = this.hasLocalStorageKeyValue
? this.getStoredTab() || this.defaultTabValue
: this.defaultTabValue;
this.updateClasses(selectedTab);
};
getStoredTab() {
const tabs = JSON.parse(localStorage.getItem("tabs") || "{}");
return tabs[this.localStorageKeyValue];
}
storeTab(selectedId) {
const tabs = JSON.parse(localStorage.getItem("tabs") || "{}");
tabs[this.localStorageKeyValue] = selectedId;
localStorage.setItem("tabs", JSON.stringify(tabs));
}
updateClasses = (selectedId) => {
this.btnTargets.forEach((btn) => {
btn.classList.remove(...this.activeClasses);
btn.classList.remove(...this.inactiveClasses);
});
this.tabTargets.forEach((tab) => tab.classList.add("hidden"));
this.btnTargets.forEach((btn) => {
if (btn.dataset.id === selectedId) {
btn.classList.add(...this.activeClasses);
} else {
btn.classList.add(...this.inactiveClasses);
}
});
this.tabTargets.forEach((tab) => {
if (tab.id === selectedId) {
tab.classList.remove("hidden");
}
});
};
}

View file

@ -21,59 +21,71 @@
</details>
<% end %>
<%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab") do |container| %>
<% container.with_tab(id: "assets", label: "Assets") do %>
<%= render LinkComponent.new(
text: "New asset",
variant: "ghost",
href: new_account_path(step: "method_select", classification: "asset"),
icon: "plus",
frame: :modal,
full_width: true,
class: "justify-start"
) %>
<%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %>
<% tabs.with_nav do |nav| %>
<% nav.with_btn(id: "assets", label: "Assets") %>
<% nav.with_btn(id: "debts", label: "Debts") %>
<% nav.with_btn(id: "all", label: "All") %>
<% end %>
<% tabs.with_panel(tab_id: "assets") do %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("asset").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
<%= render LinkComponent.new(
text: "New asset",
variant: "ghost",
href: new_account_path(step: "method_select", classification: "asset"),
icon: "plus",
frame: :modal,
full_width: true,
class: "justify-start"
) %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("asset").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<% end %>
<% container.with_tab(id: "debts", label: "Debts") do %>
<%= render LinkComponent.new(
text: "New debt",
variant: "ghost",
href: new_account_path(step: "method_select", classification: "liability"),
icon: "plus",
frame: :modal,
full_width: true,
class: "justify-start"
) %>
<% tabs.with_panel(tab_id: "debts") do %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("liability").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
<%= render LinkComponent.new(
text: "New debt",
variant: "ghost",
href: new_account_path(step: "method_select", classification: "liability"),
icon: "plus",
frame: :modal,
full_width: true,
class: "justify-start"
) %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("liability").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<% end %>
<% container.with_tab(id: "all", label: "All") do %>
<%= render LinkComponent.new(
text: "New account",
variant: "ghost",
full_width: true,
href: new_account_path(step: "method_select"),
icon: "plus",
frame: :modal,
class: "justify-start"
) %>
<% tabs.with_panel(tab_id: "all") do %>
<div class="space-y-2">
<% family.balance_sheet.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
<%= render LinkComponent.new(
text: "New account",
variant: "ghost",
full_width: true,
href: new_account_path(step: "method_select"),
icon: "plus",
frame: :modal,
class: "justify-start"
) %>
<div class="space-y-2">
<% family.balance_sheet.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
</div>

View file

@ -1,6 +1,6 @@
<%# locals: (account:) %>
<%= render MenuComponent.new do |menu| %>
<%= render MenuComponent.new(testid: "account-menu") do |menu| %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% unless account.crypto? %>

View file

@ -1,11 +1,17 @@
<%# locals: (account:, tabs:) %>
<% selected_tab = tabs.find { |tab| tab[:key] == params[:tab] } || tabs.first %>
<% active_tab = tabs.find { |tab| tab[:key] == params[:tab] } || tabs.first %>
<div class="flex gap-2 text-sm text-primary font-medium mb-4">
<% tabs.each do |tab| %>
<%= render "accounts/show/tab", account: account, key: tab[:key], is_selected: selected_tab[:key] == tab[:key] %>
<%= render TabsComponent.new(active_tab: active_tab[:key], url_param_key: "tab") do |tabs_container| %>
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
<% tabs.each do |tab| %>
<% nav.with_btn(id: tab[:key], label: tab[:key].humanize, classes: "px-6") %>
<% end %>
<% end %>
</div>
<%= selected_tab[:contents] %>
<% tabs.each do |tab| %>
<% tabs_container.with_panel(tab_id: tab[:key]) do %>
<%= tab[:contents] %>
<% end %>
<% end %>
<% end %>

View file

@ -16,7 +16,7 @@
<%= render "accounts/show/chart", account: account %>
<% end %>
<div class="min-h-[800px]">
<div class="min-h-[800px]" data-testid="account-details>
<% if tabs.present? %>
<%= tabs %>
<% else %>

View file

@ -19,16 +19,16 @@
<% end %>
</div>
<div data-controller="menu" data-menu-placement-value="bottom-start">
<%= tag.button data: { menu_target: "button" }, class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
<%= render MenuComponent.new(variant: "button") do |menu| %>
<% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
<span class="text-primary font-medium text-3xl md:text-base"><%= @budget.name %></span>
<%= lucide_icon "chevron-down", class: "w-5 h-5 shrink-0 text-secondary" %>
<% end %>
<div data-menu-target="content" class="hidden z-10">
<% menu.with_custom_content do %>
<%= render "budgets/picker", family: Current.family, year: budget.start_date.year %>
</div>
</div>
<% end %>
<% end %>
<div class="ml-auto">
<%= render LinkComponent.new(

View file

@ -1,7 +1,7 @@
<%# locals: (family:, year:) %>
<%= turbo_frame_tag "budget_picker" do %>
<div class="bg-container shadow-border-xs p-3 rounded-xl space-y-4">
<div class="p-3 space-y-4">
<div class="flex items-center gap-2 justify-between">
<% last_month_of_previous_year = Date.new(year - 1, 12, 1) %>

View file

@ -11,8 +11,13 @@
<p class="text-secondary text-sm"><%= t(".description") %></p>
</div>
<%= render TabsComponent.new(active_tab: "csv-upload", url_param_key: "tab") do |container| %>
<% container.with_tab(id: "csv-upload", label: "Upload CSV") do %>
<%= render TabsComponent.new(active_tab: params[:tab] || "csv-upload", url_param_key: "tab", testid: "import-tabs") do |tabs| %>
<% tabs.with_nav do |nav| %>
<% nav.with_btn(id: "csv-upload", label: "Upload CSV") %>
<% nav.with_btn(id: "csv-paste", label: "Copy & Paste") %>
<% end %>
<% tabs.with_panel(tab_id: "csv-upload") do %>
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
@ -42,7 +47,7 @@
<% end %>
<% end %>
<% container.with_tab(id: "csv-paste", label: "Copy & Paste") do %>
<% tabs.with_panel(tab_id: "csv-paste") do %>
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
<%= form.select :col_sep, Import::SEPARATORS, label: true %>

View file

@ -5,9 +5,9 @@
<p class="text-secondary text-sm"><%= t(".description") %></p>
</div>
<%= styled_form_with model: Setting.new,
url: settings_hosting_path,
method: :patch,
<%= styled_form_with model: Setting.new,
url: settings_hosting_path,
method: :patch,
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
<%= form.toggle :require_invite_for_signup, { data: { auto_submit_form_target: "auto" } } %>
<% end %>

View file

@ -16,17 +16,20 @@
"data-auto-submit-form-target": "auto" %>
</div>
</div>
<div data-controller="menu" class="relative">
<%= render ButtonComponent.new(
text: "Filter",
icon: "list-filter",
variant: "outline",
<%= render MenuComponent.new(variant: "button", no_padding: true) do |menu| %>
<% menu.with_button(
id: "transaction-filters-button",
type: "button",
text: "Filter",
variant: "outline",
icon: "list-filter",
data: { menu_target: "button" }
) %>
<%= render "transactions/searches/menu", form: form %>
</div>
<% menu.with_custom_content do %>
<%= render "transactions/searches/menu", form: form %>
<% end %>
<% end %>
</div>
<% end %>

View file

@ -1,49 +1,46 @@
<%# locals: (form:) %>
<div
id="transaction-filters-menu"
data-menu-target="content"
data-controller="tabs"
data-tabs-active-class="bg-surface text-primary"
data-tabs-default-tab-value="<%= get_default_transaction_search_filter[:key] %>"
class="hidden absolute flex flex-col md:flex-row z-10 h-auto md:h-80 w-full md:w-[540px] top-12 right-0 shadow-border-xs bg-container rounded-lg overflow-hidden">
<div class="flex w-full md:w-44 flex-row md:flex-col items-start p-3 text-sm font-medium text-secondary border-b md:border-b-0 md:border-r border-secondary overflow-x-auto md:overflow-x-visible">
<% transaction_search_filters.each do |filter| %>
<button
class="flex text-secondary hover:bg-container-inset items-center gap-2 px-3 rounded-md py-2 min-w-max md:w-full"
type="button"
data-id="<%= filter[:key] %>"
data-tabs-target="btn"
data-action="tabs#select">
<%= lucide_icon(filter[:icon], class: "w-5 h-5") %>
<span class="text-sm font-medium"><%= t(".#{filter[:key]}") %></span>
</button>
<% end %>
</div>
<div class="flex flex-col grow">
<div class="grow p-3 border-b border-secondary overflow-y-auto max-h-[50vh] md:max-h-none">
<%= render TabsComponent.new(
variant: :unstyled,
active_tab: get_default_transaction_search_filter[:key],
active_btn_classes: "bg-surface text-primary",
inactive_btn_classes: "text-secondary hover:bg-container-inset"
) do |tabs| %>
<div id="transaction-filters-menu" class="flex flex-col md:flex-row h-[50vh] lg:max-h-auto z-10 md:h-80 w-full md:w-[540px] top-12 right-0 overflow-hidden">
<%= tabs.with_nav(classes: "shrink-0 flex w-full md:w-44 flex-row md:flex-col items-start p-3 text-sm font-medium text-secondary border-b md:border-b-0 md:border-r border-secondary overflow-x-auto md:overflow-x-visible") do |nav| %>
<% transaction_search_filters.each do |filter| %>
<div id="<%= filter[:key] %>" data-tabs-target="tab">
<%= render partial: get_transaction_search_filter_partial_path(filter), locals: { form: form } %>
</div>
<%= nav.with_btn(id: filter[:key], label: filter[:label], classes: "w-full px-3 py-2 flex gap-2 items-center rounded-md") do %>
<%= lucide_icon(filter[:icon], class: "w-5 h-5 fg-gray") %>
<%= tag.span(filter[:label], class: "text-sm font-medium") %>
<% end %>
<% end %>
</div>
<% end %>
<div class="flex justify-between items-center gap-2 bg-container p-3">
<div>
<% if @q.present? %>
<%= render LinkComponent.new(
<div class="flex flex-col grow overflow-y-auto">
<div class="grow p-3 border-b border-secondary overflow-y-auto">
<% transaction_search_filters.each do |filter| %>
<%= tabs.with_panel(tab_id: filter[:key]) do %>
<%= render partial: get_transaction_search_filter_partial_path(filter), locals: { form: form } %>
<% end %>
<% end %>
</div>
<div class="flex justify-between items-center gap-2 bg-container p-3 shrink-0">
<div>
<% if @q.present? %>
<%= render LinkComponent.new(
text: t(".clear_filters"),
variant: "ghost",
href: transactions_path(clear_filters: true),
) %>
<% end %>
</div>
<% end %>
</div>
<div>
<%= render ButtonComponent.new(text: t(".cancel"), type: "button", variant: "ghost", data: { action: "menu#close" }) %>
<%= render ButtonComponent.new(text: t(".apply")) %>
<div>
<%= render ButtonComponent.new(text: t(".cancel"), type: "button", variant: "ghost", data: { action: "menu#close" }) %>
<%= render ButtonComponent.new(text: t(".apply")) %>
</div>
</div>
</div>
</div>
</div>
<% end %>

View file

@ -5,6 +5,7 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/components", under: "controllers", to: ""
pin_all_from "app/javascript/services", under: "services", to: "services"
pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.0
pin "@simonwep/pickr", to: "@simonwep--pickr.js" # @1.9.1

View file

@ -4,4 +4,4 @@
Rails.application.config.assets.version = "1.0"
# Add additional assets to the asset load path.
# Rails.application.config.assets.paths << Emoji.images_path
Rails.application.config.assets.paths << "app/components"

View file

@ -32,4 +32,10 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
# Trigger Capybara's wait mechanism to avoid timing issues with logout
find("h2", text: "Sign in to your account")
end
def within_testid(testid)
within "[data-testid='#{testid}']" do
yield
end
end
end

View file

@ -1,14 +1,7 @@
class TabsComponentPreview < ViewComponent::Preview
# @display container_classes max-w-[400px]
def default
render TabsComponent.new(active_tab: "tab1") do |container|
container.with_tab(id: "tab1", label: "Tab 1") do
content_tag(:p, "Content for tab 1")
end
end
container.with_tab(id: "tab2", label: "Tab 2") do
content_tag(:p, "Content for tab 2")
end
end
def custom
end
end

View file

@ -0,0 +1,29 @@
<%= render TabsComponent.new(
variant: :unstyled,
active_tab: "tab1",
active_btn_classes: "bg-white text-primary",
inactive_btn_classes: "text-secondary",
) do |tabs| %>
<div class="flex border border-secondary rounded-lg h-full max-w-[400px]">
<%= tabs.with_nav(classes: "flex flex-col py-2 px-3 border-r border-secondary") do |nav| %>
<%= nav.with_btn(id: "tab1", label: "Tab 1", classes: "px-2 py-1 rounded-md w-full whitespace-nowrap") %>
<%= nav.with_btn(id: "tab2", label: "Tab 2", classes: "px-2 py-1 rounded-md w-full whitespace-nowrap") %>
<% end %>
<div class="flex flex-col w-full">
<div class="h-[200px] p-4">
<%= tabs.with_panel(tab_id: "tab1") do %>
<%= content_tag(:p, "Content for tab 1") %>
<% end %>
<%= tabs.with_panel(tab_id: "tab2") do %>
<%= content_tag(:p, "Content for tab 2") %>
<% end %>
</div>
<div class="w-full border-t border-secondary p-4">
Footer
</div>
</div>
</div>
<% end %>

View file

@ -0,0 +1,16 @@
<div class="max-w-[400px]">
<%= render TabsComponent.new(active_tab: "tab1") do |tabs| %>
<%= tabs.with_nav do |tab_nav| %>
<%= tab_nav.with_btn(id: "tab1", label: "Tab 1") %>
<%= tab_nav.with_btn(id: "tab2", label: "Tab 2") %>
<% end %>
<%= tabs.with_panel(tab_id: "tab1") do %>
<%= content_tag(:p, "Content for tab 1") %>
<% end %>
<%= tabs.with_panel(tab_id: "tab2") do %>
<%= content_tag(:p, "Content for tab 2") %>
<% end %>
<% end %>
</div>

View file

@ -92,7 +92,8 @@ class AccountsTest < ApplicationSystemTestCase
click_button "Create Account"
within "[data-controller='tabs']" do
within_testid("account-sidebar-tabs") do
click_on "All"
find("details", text: Accountable.from_type(accountable_type).display_name).click
assert_text account_name
end
@ -104,8 +105,8 @@ class AccountsTest < ApplicationSystemTestCase
visit account_url(created_account)
within "header:has(button[data-menu-target='button'])" do
find('button[data-menu-target="button"]').click
within_testid("account-menu") do
find("button").click
click_on "Edit"
end

View file

@ -15,7 +15,9 @@ class ImportsTest < ApplicationSystemTestCase
click_on "Import transactions"
find("button[data-id='csv-paste-tab']").click
within_testid("import-tabs") do
click_on "Copy & Paste"
end
fill_in "import[raw_file_str]", with: file_fixture("imports/transactions.csv").read
@ -63,7 +65,9 @@ class ImportsTest < ApplicationSystemTestCase
click_on "Import investments"
find("button[data-id='csv-paste-tab']").click
within_testid("import-tabs") do
click_on "Copy & Paste"
end
fill_in "import[raw_file_str]", with: file_fixture("imports/trades.csv").read
@ -103,7 +107,9 @@ class ImportsTest < ApplicationSystemTestCase
click_on "Import accounts"
find("button[data-id='csv-paste-tab']").click
within_testid("import-tabs") do
click_on "Copy & Paste"
end
fill_in "import[raw_file_str]", with: file_fixture("imports/accounts.csv").read
@ -149,7 +155,9 @@ class ImportsTest < ApplicationSystemTestCase
click_on "Import from Mint"
find("button[data-id='csv-paste-tab']").click
within_testid("import-tabs") do
click_on "Copy & Paste"
end
fill_in "import[raw_file_str]", with: file_fixture("imports/mint.csv").read

View file

@ -39,7 +39,7 @@ class SettingsTest < ApplicationSystemTestCase
click_link "Self hosting"
assert_current_path settings_hosting_path
assert_selector "h1", text: "Self-Hosting"
check "setting_require_invite_for_signup", allow_label_click: true
check "setting[require_invite_for_signup]", allow_label_click: true
click_button "Generate new code"
assert_selector 'span[data-clipboard-target="source"]', visible: true, count: 1 # invite code copy widget
copy_button = find('button[data-action="clipboard#copy"]', match: :first) # Find the first copy button (adjust if needed)