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:
parent
f6016e47e2
commit
83bd001637
30 changed files with 365 additions and 287 deletions
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
29
app/components/tabs/nav_component.rb
Normal file
29
app/components/tabs/nav_component.rb
Normal 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
|
11
app/components/tabs/panel_component.rb
Normal file
11
app/components/tabs/panel_component.rb
Normal 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
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
42
app/components/tabs_controller.js
Normal file
42
app/components/tabs_controller.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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, " ".html_safe, class: label_classes, for: id %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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? %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) %>
|
||||
|
||||
|
|
|
@ -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 %>
|
||||
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %>
|
|
@ -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>
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue