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

Replace all menus with new ViewComponent

This commit is contained in:
Zach Gollwitzer 2025-04-25 19:37:56 -04:00
parent 641f32d0d8
commit 2bfd00f611
21 changed files with 209 additions and 243 deletions

View file

@ -1,6 +1,6 @@
<div data-controller="menu" data-menu-placement-value="<%= @placement %>" data-menu-offset-value="<%= @offset %>">
<% if @variant == :icon %>
<%= render ButtonComponent.new(variant: "icon", icon: "more-horizontal", data: { menu_target: "button" }) %>
<%= render ButtonComponent.new(variant: "icon", icon: @icon_vertical ? "more-vertical" : "more-horizontal", data: { menu_target: "button" }) %>
<% elsif @variant == :button %>
<%= button %>
<% elsif @variant == :avatar %>
@ -18,6 +18,8 @@
<% items.each do |item| %>
<%= item %>
<% end %>
<%= custom_content %>
</div>
</div>
</div>

View file

@ -1,15 +1,22 @@
# frozen_string_literal: true
class MenuComponent < ViewComponent::Base
renders_one :button, ->(**options) do
renders_one :button, ->(**options, &block) do
options_with_target = options.merge(data: { menu_target: "button" })
ButtonComponent.new(**options_with_target)
if block
content_tag(:button, **options_with_target, &block)
else
ButtonComponent.new(**options_with_target)
end
end
renders_one :header, ->(&block) do
content_tag(:div, class: "border-b border-tertiary", &block)
end
renders_one :custom_content
renders_many :items, MenuItemComponent
VARIANTS = {
@ -18,10 +25,11 @@ class MenuComponent < ViewComponent::Base
avatar: {}
}
def initialize(variant: "icon", avatar_url: nil, placement: "bottom-end", offset: 12)
def initialize(variant: "icon", avatar_url: nil, placement: "bottom-end", offset: 12, icon_vertical: false)
@variant = variant.to_sym
@avatar_url = avatar_url
@placement = placement
@offset = offset
@icon_vertical = icon_vertical
end
end

View file

@ -14,13 +14,14 @@ class MenuItemComponent < ViewComponent::Base
<% end %>
ERB
def initialize(variant: "default", text: nil, href: nil, method: :get, icon: nil, data: {})
def initialize(variant: "default", text: nil, href: nil, method: :get, icon: nil, destructive: false, data: {})
@variant = variant.to_sym
@text = text
@icon = icon
@href = href
@method = method.to_sym
@data = data
@destructive = destructive
end
def wrapper(&block)
@ -39,7 +40,7 @@ class MenuItemComponent < ViewComponent::Base
end
def destructive?
@method == :delete
@method == :delete || @destructive
end
private

View file

@ -1,47 +0,0 @@
module MenusHelper
def contextual_menu(icon: "more-horizontal", id: nil, &block)
tag.div id: id, data: { controller: "menu" } do
concat contextual_menu_icon(icon)
concat contextual_menu_content(&block)
end
end
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal, class_name: nil)
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2 #{class_name}", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
concat(tag.span(label, class: "text-sm"))
end
end
def contextual_menu_item(label, url:, icon:, turbo_frame: nil)
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
concat(tag.span(label, class: "text-sm"))
end
end
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
button_to url,
method: :delete,
class: "flex items-center w-full rounded-md text-red-500 hover:bg-red-500/5 p-2 gap-2",
data: { turbo_confirm: turbo_confirm, turbo_frame: } do
concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5"))
concat(tag.span(label, class: "text-sm"))
end
end
private
def contextual_menu_icon(icon)
tag.button class: "w-9 h-9 flex justify-center items-center hover:bg-surface-hover rounded-lg cursor-pointer focus:outline-none focus-visible:outline-none", data: { menu_target: "button" } do
concat lucide_icon("more-vertical", class: "w-5 h-5 text-secondary md:hidden")
concat lucide_icon(icon, class: "w-5 h-5 text-secondary hidden md:block")
end
end
def contextual_menu_content(&block)
tag.div class: "min-w-[200px] p-1 z-50 shadow-border-xs bg-container rounded-lg hidden",
data: { menu_target: "content" } do
capture(&block)
end
end
end

View file

@ -45,7 +45,13 @@ export default class extends Controller {
// If data is a string, it's the title. Otherwise, return the parsed object.
#normalizeRawData(rawData) {
try {
return JSON.parse(rawData);
const parsed = JSON.parse(rawData);
if (typeof parsed === "boolean") {
return { title: "Are you sure?" };
}
return parsed;
} catch (e) {
return { title: rawData };
}

View file

@ -1,73 +1,87 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = { userPreference: String }
static values = { userPreference: String };
connect() {
this.applyTheme()
this.startSystemThemeListener()
this.applyTheme();
this.startSystemThemeListener();
}
disconnect() {
this.stopSystemThemeListener()
this.stopSystemThemeListener();
}
// Called automatically by Stimulus when the userPreferenceValue changes (e.g., after form submit/page reload)
userPreferenceValueChanged() {
this.applyTheme()
this.applyTheme();
}
// Called when a theme radio button is clicked
updateTheme(event) {
const selectedTheme = event.currentTarget.value
const selectedTheme = event.currentTarget.value;
if (selectedTheme === "system") {
this.setTheme(this.systemPrefersDark())
this.setTheme(this.systemPrefersDark());
} else if (selectedTheme === "dark") {
this.setTheme(true)
this.setTheme(true);
} else {
this.setTheme(false)
this.setTheme(false);
}
}
// Applies theme based on the userPreferenceValue (from server)
applyTheme() {
if (this.userPreferenceValue === "system") {
this.setTheme(this.systemPrefersDark())
this.setTheme(this.systemPrefersDark());
} else if (this.userPreferenceValue === "dark") {
this.setTheme(true)
this.setTheme(true);
} else {
this.setTheme(false)
this.setTheme(false);
}
}
// Sets or removes the data-theme attribute
setTheme(isDark) {
if (isDark) {
document.documentElement.setAttribute("data-theme", "dark")
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.removeAttribute("data-theme")
document.documentElement.removeAttribute("data-theme");
}
}
systemPrefersDark() {
return window.matchMedia("(prefers-color-scheme: dark)").matches
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
handleSystemThemeChange = (event) => {
// Only apply system theme changes if the user preference is currently 'system'
if (this.userPreferenceValue === "system") {
this.setTheme(event.matches)
this.setTheme(event.matches);
}
};
toDark() {
this.setTheme(true);
}
toLight() {
this.setTheme(false);
}
startSystemThemeListener() {
this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
this.darkMediaQuery.addEventListener("change", this.handleSystemThemeChange)
this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
this.darkMediaQuery.addEventListener(
"change",
this.handleSystemThemeChange,
);
}
stopSystemThemeListener() {
if (this.darkMediaQuery) {
this.darkMediaQuery.removeEventListener("change", this.handleSystemThemeChange)
this.darkMediaQuery.removeEventListener(
"change",
this.handleSystemThemeChange,
);
}
}
}
}

View file

@ -8,19 +8,15 @@
<%= render partial: "categories/badge", locals: { category: category } %>
</div>
<div class="justify-self-end">
<%= contextual_menu do %>
<%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(text: t(".edit"), icon: "pencil", href: edit_category_path(category), data: { turbo_frame: :modal }) %>
<% if category.transactions.any? %>
<%= link_to new_category_deletion_path(category),
class: "flex items-center w-full rounded-lg text-red-600 hover:bg-red-50 py-2 px-3 gap-2",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
<span class="text-sm"><%= t(".delete") %></span>
<% end %>
<% menu.with_item(text: t(".delete"), icon: "trash-2", href: new_category_deletion_path(category), data: { turbo_frame: :modal }, method: :delete) %>
<% else %>
<%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %>
<% menu.with_item(text: t(".delete"), icon: "trash-2", href: category_path(category), method: :delete) %>
<% end %>
<% end %>
</div>

View file

@ -1,16 +1,15 @@
<%# locals: (transaction:) %>
<div class="relative" data-controller="menu" id="<%= dom_id(transaction, :category_menu) %>">
<button data-menu-target="button" class="flex cursor-pointer">
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
</button>
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
<div class="w-80 text-sm font-semibold leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-secondary animate-pulse"><%= t(".loading") %></p>
</div>
<% end %>
</div>
</div>
</div>
<%= render MenuComponent.new(variant: "button") do |menu| %>
<% menu.with_button do %>
<% render partial: "categories/badge", locals: { category: transaction.category } %>
<% end %>
<% menu.with_custom_content do %>
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-secondary animate-pulse"><%= t(".loading") %></p>
</div>
<% end %>
<% end %>
<% end %>

View file

@ -24,23 +24,8 @@
<%= render partial: "categories/badge", locals: { category: category } %>
<% end %>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to edit_category_path(category),
class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-secondary" %>
<span><%= t(".edit") %></span>
<% end %>
<%= link_to new_category_deletion_path(category),
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
<span><%= t(".delete") %></span>
<% end %>
</div>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(text: t(".edit"), icon: "pencil-line", href: edit_category_path(category), data: { turbo_frame: :modal }) %>
<% menu.with_item(text: t(".delete"), icon: "trash-2", href: new_category_deletion_path(category), data: { turbo_frame: :modal }, destructive: true) %>
<% end %>
<% end %>

View file

@ -9,8 +9,18 @@
</p>
</div>
<%= contextual_menu icon: "more-vertical" do %>
<%= contextual_menu_item("Edit chat", url: edit_chat_path(chat), icon: "pencil", turbo_frame: dom_id(chat, :title)) %>
<%= contextual_menu_destructive_item("Delete chat", chat_path(chat)) %>
<%= render MenuComponent.new(icon_vertical: true) do |menu| %>
<% menu.with_item(text: "Edit chat", href: edit_chat_path(chat), icon: "pencil", data: { turbo_frame: dom_id(chat, "title") }) %>
<% menu.with_item(
text: "Delete chat",
href: chat_path(chat),
icon: "trash-2",
method: :delete,
data: {
turbo_confirm: {
title: "Are you sure you want to delete this chat?",
variant: "outline-destructive"
}
}) %>
<% end %>
<% end %>

View file

@ -13,12 +13,22 @@
</div>
</div>
<%= contextual_menu icon: "more-vertical", id: "chat-menu" do %>
<%= contextual_menu_item "Start new chat", url: new_chat_path, icon: "plus" %>
<%= render MenuComponent.new(icon_vertical: true) do |menu| %>
<% menu.with_item(text: "Start new chat", href: new_chat_path, icon: "plus") %>
<% unless chat.new_record? %>
<%= contextual_menu_item "Edit chat title", url: edit_chat_path(chat, ctx: "chat"), icon: "pencil", turbo_frame: dom_id(chat, "title") %>
<%= contextual_menu_destructive_item "Delete chat", chat_path(chat), turbo_confirm: "Are you sure you want to delete this chat?" %>
<% menu.with_item(text: "Edit chat title", href: edit_chat_path(chat, ctx: "chat"), icon: "pencil", data: { turbo_frame: dom_id(chat, "title") }) %>
<% menu.with_item(
text: "Delete chat",
href: chat_path(chat),
icon: "trash-2",
method: :delete,
data: {
turbo_confirm: {
title: "Are you sure you want to delete this chat?",
variant: "outline-destructive"
}
}) %>
<% end %>
<% end %>
</nav>

View file

@ -15,17 +15,13 @@
</p>
</div>
<div class="justify-self-end">
<%= contextual_menu do %>
<%= contextual_menu_modal_action_item t(".edit"), edit_family_merchant_path(family_merchant), icon: "pencil", turbo_frame: "modal" %>
<%= contextual_menu_destructive_item "Delete",
family_merchant_path(family_merchant),
turbo_frame: "_top",
turbo_confirm: family_merchant.transactions.any? ? {
title: "Delete #{family_merchant.name}?",
body: "This will remove this merchant from all transactions it has been assigned to.",
accept: "Delete"
} : nil %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(text: "Edit", href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% menu.with_item(text: "Delete", href: family_merchant_path(family_merchant), icon: "trash-2", method: :delete, data: { turbo_confirm: {
title: "Delete #{family_merchant.name}?",
body: "This will remove this merchant from all transactions it has been assigned to.",
variant: "outline-destructive"
} }) %>
<% end %>
</div>
</div>

View file

@ -36,34 +36,36 @@
<% end %>
</div>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to import_path(import),
class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "eye", class: "w-5 h-5 text-secondary" %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(text: t(".view"), href: import_path(import), icon: "eye") %>
<span><%= t(".view") %></span>
<% end %>
<% if import.complete? || import.revert_failed? %>
<% menu.with_item(
text: t(".revert"),
href: revert_import_path(import),
icon: "rotate-ccw",
method: :put,
data: {
turbo_confirm: {
title: "Revert import?",
body: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time.",
accept: "Revert"
}
}) %>
<% if import.complete? || import.revert_failed? %>
<%= button_to revert_import_path(import),
method: :put,
class: "block w-full py-2 px-3 space-x-2 text-orange-600 hover:bg-orange-50 flex items-center rounded-lg",
data: { turbo_confirm: true } do %>
<%= lucide_icon "rotate-ccw", class: "w-5 h-5" %>
<span>Revert</span>
<% end %>
<% else %>
<%= button_to import_path(import),
method: :delete,
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
data: { turbo_confirm: true } do %>
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
<span><%= t(".delete") %></span>
<% end %>
<% end %>
</div>
<% else %>
<% menu.with_item(
text: t(".delete"),
href: import_path(import),
icon: "trash-2",
method: :delete,
data: {
turbo_confirm: {
title: "Delete import?",
body: "This will delete the import and is not reversible.",
variant: "outline-destructive"
}
}) %>
<% end %>
<% end %>
</div>

View file

@ -1,7 +1,7 @@
<dialog id="confirm-dialog" data-controller="confirm-dialog" class="backdrop:bg-overlay bg-transparent m-auto p-1">
<form method="dialog" class="p-4 bg-container rounded-xl shadow-border-xs space-y-4 min-w-full lg:min-w-[300px] lg:max-w-[400px]">
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-2">
<h3 class="font-medium text-primary" data-confirm-dialog-target="title">Are you sure?</h3>
<%= render ButtonComponent.new(

View file

@ -20,6 +20,13 @@
<%= family_stream %>
<% if Rails.env.development? %>
<div class="fixed bottom-10 left-2">
<%= render ButtonComponent.new(variant: "icon", icon: "eclipse", data: { action: "theme#toDark" }) %>
<%= render ButtonComponent.new(variant: "icon", icon: "sun", data: { action: "theme#toLight" }) %>
</div>
<% end %>
<%= turbo_frame_tag "modal" %>
<%= turbo_frame_tag "drawer" %>

View file

@ -15,19 +15,19 @@
</p>
</div>
<div class="justify-self-end">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= contextual_menu_modal_action_item t(".edit"), edit_merchant_path(merchant) %>
<%= contextual_menu_destructive_item t(".delete"),
merchant_path(merchant),
turbo_frame: "_top",
turbo_confirm: merchant.transactions.any? ? {
title: t(".confirm_title"),
body: t(".confirm_body"),
accept: t(".confirm_accept")
} : nil %>
</div>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(text: t(".edit"), href: edit_merchant_path(merchant), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% menu.with_item(
text: t(".delete"),
href: merchant_path(merchant),
icon: "trash-2",
method: :delete,
data: { turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body"),
confirmText: t(".confirm_accept"),
variant: "outline-destructive"
} }) %>
<% end %>
</div>
</div>

View file

@ -107,24 +107,13 @@
) %>
<% end %>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= button_to plaid_item_path(plaid_item),
method: :delete,
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion?,
data: {
turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body"),
accept: t(".confirm_accept")
}
} do %>
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
<span><%= t(".delete") %></span>
<% end %>
</div>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item text: t(".delete"), icon: "trash-2", href: plaid_item_path(plaid_item), method: :delete, data: { turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body"),
accept: t(".confirm_accept"),
variant: "destructive"
} } %>
<% end %>
</div>
</summary>

View file

@ -44,18 +44,15 @@
<div class="flex items-center gap-4">
<%= render "shared/toggle_form", model: rule, attribute: :active %>
<%= contextual_menu icon: "more-vertical", id: "chat-menu" do %>
<%= contextual_menu_item "Edit", url: edit_rule_path(rule), icon: "pencil", turbo_frame: "modal" %>
<%= contextual_menu_item "Re-apply rule", url: confirm_rule_path(rule), turbo_frame: "modal", icon: "refresh-cw" %>
<% turbo_confirm = {
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% menu.with_item(text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %>
<% menu.with_item(text: "Delete", href: rule_path(rule), icon: "trash-2", method: :delete, data: { turbo_confirm: {
title: "Delete rule",
body: "Are you sure you want to delete this rule? Data affected by this rule will no longer be automatically updated. This action cannot be undone.",
accept: "Delete rule",
} %>
<%= contextual_menu_destructive_item "Delete", rule_path(rule), turbo_confirm: turbo_confirm %>
confirmText: "Delete rule",
variant: "outline-destructive"
} }) %>
<% end %>
</div>
</div>

View file

@ -1,16 +1,22 @@
<header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">Rules</h1>
<% turbo_confirm = {
title: "Delete all rules",
body: "Are you sure you want to delete all rules? This action cannot be undone.",
accept: "Delete all rules",
} %>
<div class="flex items-center gap-2">
<% if @rules.any? %>
<%= contextual_menu do %>
<%= contextual_menu_destructive_item "Delete all rules", destroy_all_rules_path, turbo_confirm: turbo_confirm %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(
text: "Delete all rules",
href: destroy_all_rules_path,
icon: "trash-2",
method: :delete,
data: {
turbo_confirm: {
title: "Delete all rules",
body: "Are you sure you want to delete all rules? This action cannot be undone.",
confirmText: "Delete all rules",
variant: "destructive"
}
}) %>
<% end %>
<% end %>

View file

@ -8,21 +8,15 @@
</p>
</div>
<div class="justify-self-end">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= contextual_menu_modal_action_item t(".edit"), edit_tag_path(tag) %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(text: t(".edit"), href: edit_tag_path(tag), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% if tag.transactions.any? %>
<%= link_to new_tag_deletion_path(tag),
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
<span><%= t(".delete") %></span>
<% end %>
<% else %>
<%= contextual_menu_destructive_item t(".delete"), tag_path(tag), turbo_confirm: nil %>
<% end %>
</div>
<% menu.with_item(text: t(".delete"), href: tag_path(tag), icon: "trash-2", method: :delete, data: { turbo_confirm: {
title: "Delete tag",
body: "Are you sure you want to delete this tag and remove it from assigned transactions? This action cannot be undone.",
confirmText: "Delete tag",
variant: "outline-destructive"
} }) %>
<% end %>
</div>
</div>

View file

@ -3,24 +3,15 @@
<h1 class="text-xl">Transactions</h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<% if Rails.env.development? %>
<%= render ButtonComponent.new(
text: "Dev only: Sync all",
variant: "ghost",
href: sync_all_accounts_path,
method: :post,
full_width: true
) %>
<% end %>
<%= contextual_menu_item "New rule", url: new_rule_path(resource_type: "transaction"), icon: "plus", turbo_frame: :modal %>
<%= contextual_menu_item "Edit rules", url: rules_path, icon: "git-branch", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_merchants"), family_merchants_path, icon: "store", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".import"), new_import_path, icon: "download", turbo_frame: "modal", class_name: "md:!hidden" %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(text: "Dev only: Sync all", href: sync_all_accounts_path, method: :post, icon: "refresh-cw") %>
<% menu.with_item(text: "New rule", href: new_rule_path(resource_type: "transaction"), icon: "plus", data: { turbo_frame: :modal }) %>
<% menu.with_item(text: "Edit rules", href: rules_path, icon: "git-branch", data: { turbo_frame: :_top }) %>
<% menu.with_item(text: "Edit categories", href: categories_path, icon: "shapes", data: { turbo_frame: :_top }) %>
<% menu.with_item(text: "Edit tags", href: tags_path, icon: "tags", data: { turbo_frame: :_top }) %>
<% menu.with_item(text: "Edit merchants", href: family_merchants_path, icon: "store", data: { turbo_frame: :_top }) %>
<% menu.with_item(text: "Edit imports", href: imports_path, icon: "hard-drive-upload", data: { turbo_frame: :_top }) %>
<% menu.with_item(text: "Import", href: new_import_path, icon: "download", data: { turbo_frame: "modal", class_name: "md:!hidden" }) %>
<% end %>
<%= render ButtonComponent.new(