1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

Implement transaction category management (#688)

* Singularize "transaction" in transaction-nested paths

* Refactor category badge partial

* Let modal content define its width

* Add contectual menu to transactions index

* Add null_category helper

* Implement category edits

* Fix inline transaction category badges

* Fix typos in system test paths

* Add missing translations

* Add decoration to color select controller

* Wire up transaction category creation

* Fix indent in color-select-controller

* Add button for clearing category from transaction

* Implement category deletions

* Fix existing modal sizes

* Use null_category in a single place

* Remove anemic method in category deletion controller

* reassign_and_destroy -> reassign_transactions_then_destroy

* Fix i18n

* Remove destroy action from CategoriesController callbacks

* transactions_merchant -> transaction_merchant

* reassign_transactions_then_destroy -> replace_and_destroy

* Add transaction category CRUD tests

* Add presence check for transaction_id

* Check replacement_category_id presence

* Test Transaction::Category#replace_and_destroy!
This commit is contained in:
Jose Farias 2024-05-02 07:24:31 -06:00 committed by GitHub
parent dc024d63b0
commit 4c5f8263bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 580 additions and 145 deletions

View file

@ -0,0 +1,24 @@
class Transactions::Categories::DeletionsController < ApplicationController
before_action :set_category
before_action :set_replacement_category, only: :create
def new
end
def create
@category.replace_and_destroy! @replacement_category
redirect_back_or_to transactions_path, notice: t(".success")
end
private
def set_category
@category = Current.family.transaction_categories.find(params[:transaction_category_id])
end
def set_replacement_category
if params[:replacement_category_id].present?
@replacement_category = Current.family.transaction_categories.find(params[:replacement_category_id])
end
end
end

View file

@ -1,36 +1,44 @@
class Transactions::CategoriesController < ApplicationController
before_action :set_category, only: [ :update, :destroy ]
before_action :set_category, only: %i[ edit update ]
before_action :set_transaction, only: :create
def index
@categories = Current.family.transaction_categories.alphabetically
end
def new
@category = Current.family.transaction_categories.new color: Transaction::Category::COLORS.sample
end
def create
if Current.family.transaction_categories.create(category_params)
redirect_to transactions_path, notice: t(".success")
else
render transactions_path, status: :unprocessable_entity, notice: t(".error")
Transaction::Category.transaction do
category = Current.family.transaction_categories.create!(category_params)
@transaction.update!(category_id: category.id) if @transaction
end
redirect_back_or_to transactions_path, notice: t(".success")
end
def edit
end
def update
if @category.update(category_params)
redirect_to transactions_path, notice: t(".success")
else
render transactions_path, status: :unprocessable_entity, notice: t(".error")
end
end
@category.update! category_params
def destroy
@category.destroy!
redirect_to transactions_path, notice: t(".success")
redirect_back_or_to transactions_path, notice: t(".success")
end
private
def set_category
@category = Current.family.transaction_categories.find(params[:id])
end
def set_transaction
if params[:transaction_id].present?
@transaction = Current.family.transactions.find(params[:transaction_id])
end
end
def category_params
params.require(:transaction_category).permit(:name, :color)
end

View file

@ -11,9 +11,9 @@ class Transactions::MerchantsController < ApplicationController
def create
if Current.family.transaction_merchants.create(merchant_params)
redirect_to transactions_merchants_path, notice: t(".success")
redirect_to transaction_merchants_path, notice: t(".success")
else
render transactions_merchants_path, status: :unprocessable_entity, notice: t(".error")
render transaction_merchants_path, status: :unprocessable_entity, notice: t(".error")
end
end
@ -22,15 +22,15 @@ class Transactions::MerchantsController < ApplicationController
def update
if @merchant.update(merchant_params)
redirect_to transactions_merchants_path, notice: t(".success")
redirect_to transaction_merchants_path, notice: t(".success")
else
render transactions_merchants_path, status: :unprocessable_entity, notice: t(".error")
render transaction_merchants_path, status: :unprocessable_entity, notice: t(".error")
end
end
def destroy
@merchant.destroy!
redirect_to transactions_merchants_path, notice: t(".success")
redirect_to transaction_merchants_path, notice: t(".success")
end
private

View file

@ -85,7 +85,12 @@ class TransactionsController < ApplicationController
def update
respond_to do |format|
sync_start_date = [ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
sync_start_date = if transaction_params[:date]
[ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
else
@transaction.date
end
if @transaction.update(transaction_params)
@transaction.account.sync_later(sync_start_date)

View file

@ -0,0 +1,21 @@
module MenusHelper
def contextual_menu(&block)
tag.div class: "relative cursor-pointer", data: { controller: "menu" } do
concat contextual_menu_icon
concat contextual_menu_content(&block)
end
end
private
def contextual_menu_icon
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
end
end
def contextual_menu_content(&block)
tag.div class: "absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden", data: { menu_target: "content" } do
capture(&block)
end
end
end

View file

@ -1,2 +1,7 @@
module Transactions::CategoriesHelper
def null_category
Transaction::Category.new \
name: "Uncategorized",
color: Transaction::Category::UNCATEGORIZED_COLOR
end
end

View file

@ -0,0 +1,30 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [ "replacementCategoryField", "submitButton" ]
static classes = [ "dangerousAction", "safeAction" ]
static values = {
submitTextWhenReplacing: String,
submitTextWhenNotReplacing: String
}
updateSubmitButton() {
if (this.replacementCategoryFieldTarget.value) {
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
this.#markSafe()
} else {
this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue
this.#markDangerous()
}
}
#markSafe() {
this.submitButtonTarget.classList.remove(...this.dangerousActionClasses)
this.submitButtonTarget.classList.add(...this.safeActionClasses)
}
#markDangerous() {
this.submitButtonTarget.classList.remove(...this.safeActionClasses)
this.submitButtonTarget.classList.add(...this.dangerousActionClasses)
}
}

View file

@ -0,0 +1,59 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [ "input", "decoration" ]
static values = { selection: String }
connect() {
this.#renderOptions()
}
select({ target }) {
this.selectionValue = target.dataset.value
}
selectionValueChanged() {
this.#options.forEach(option => {
if (option.dataset.value === this.selectionValue) {
this.#check(option)
this.inputTarget.value = this.selectionValue
} else {
this.#uncheck(option)
}
})
}
#renderOptions() {
this.#options.forEach(option => option.style.backgroundColor = option.dataset.value)
}
#check(option) {
option.setAttribute("aria-checked", "true")
option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA(option.dataset.value, 0.2)}`
this.decorationTarget.style.backgroundColor = option.dataset.value
}
#uncheck(option) {
option.setAttribute("aria-checked", "false")
option.style.boxShadow = "none"
}
get #options() {
return Array.from(this.element.querySelectorAll("[role='radio']"))
}
}
function hexToRGBA(hex, alpha = 1) {
hex = hex.replace(/^#/, '');
if (hex.length === 8) {
alpha = parseInt(hex.slice(6, 8), 16) / 255;
hex = hex.slice(0, 6);
}
let r = parseInt(hex.slice(0, 2), 16);
let g = parseInt(hex.slice(2, 4), 16);
let b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View file

@ -46,6 +46,13 @@ class Transaction::Category < ApplicationRecord
self.insert_all(categories)
end
def replace_and_destroy!(replacement)
transaction do
transactions.update_all category_id: replacement&.id
destroy!
end
end
private
def clear_internal_category

View file

@ -63,6 +63,6 @@
<% else %>
<%= previous_setting("Billing", settings_billing_path) %>
<% end %>
<%= next_setting("Categories", transactions_categories_path) %>
<%= next_setting("Categories", transaction_categories_path) %>
</div>
</div>

View file

@ -1,6 +1,6 @@
<h1 class="text-3xl font-semibold font-display"><%= t(".title") %></h1>
<%= modal do %>
<div class="flex flex-col min-h-[530px]" data-controller="list-keyboard-navigation">
<div class="flex flex-col min-h-[530px] w-screen max-w-xl" data-controller="list-keyboard-navigation">
<% if @account.accountable.blank? %>
<div class="border-b border-alpha-black-25 p-4 text-gray-400">
<%= t ".select_accountable_type" %>

View file

@ -9,7 +9,7 @@
</div>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Rules", transactions_rules_path) %>
<%= previous_setting("Rules", transaction_rules_path) %>
<%= next_setting("Feedback", feedback_path) %>
</div>
</div>

View file

@ -47,13 +47,13 @@
</div>
<ul class="space-y-1">
<li>
<%= sidebar_link_to t(".categories_label"), transactions_categories_path, icon: "tags" %>
<%= sidebar_link_to t(".categories_label"), transaction_categories_path, icon: "tags" %>
</li>
<li>
<%= sidebar_link_to t(".merchants_label"), transactions_merchants_path, icon: "store" %>
<%= sidebar_link_to t(".merchants_label"), transaction_merchants_path, icon: "store" %>
</li>
<li>
<%= sidebar_link_to t(".rules_label"), transactions_rules_path, icon: "list-checks" %>
<%= sidebar_link_to t(".rules_label"), transaction_rules_path, icon: "list-checks" %>
</li>
</ul>
</section>

View file

@ -1,6 +1,6 @@
<%# locals: (content:, classes:) -%>
<%= turbo_frame_tag "modal" do %>
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[648px] max-w-[580px] w-full shadow-xs h-fit <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[648px] max-w-[580px] w-min-content shadow-xs h-fit <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
<div class="flex flex-col">
<%= content %>
</div>

View file

@ -13,7 +13,7 @@
<% else %>
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
<div class="w-36">
<%= render partial: "transactions/categories/badge", locals: transaction.category.nil? ? {} : { name: transaction.category.name, color: transaction.category.color } %>
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
</div>
<% end %>
<div class="ml-auto">

View file

@ -1,4 +1,10 @@
<%# locals: (name: "Uncategorized", color: Transaction::Category::UNCATEGORIZED_COLOR) %>
<% background_color = "color-mix(in srgb, #{color} 5%, white)" %>
<% border_color = "color-mix(in srgb, #{color} 10%, white)" %>
<span class="border text-sm font-medium px-2.5 py-1 rounded-full truncate" style="background-color: <%= background_color %>; border-color: <%= border_color %>; color: <%= color %>"><%= name %></span>
<%# locals: (category:) %>
<% category ||= null_category %>
<span class="border text-sm font-medium px-2.5 py-1 rounded-full cursor-pointer content-center"
style="
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
border-color: color-mix(in srgb, <%= category.color %> 10%, white);
color: <%= category.color %>;">
<%= category.name %>
</span>

View file

@ -0,0 +1,38 @@
<%= form_with model: category, data: { turbo: false } do |form| %>
<div class="flex flex-col space-y-4 w-96" data-controller="color-select" data-color-select-selection-value="<%= category.color %>">
<fieldset class="relative">
<span data-color-select-target="decoration" class="pointer-events-none absolute inset-y-3.5 left-3 flex items-center pl-1 block w-1 rounded-lg"></span>
<%= form.text_field :name,
value: category.name,
autofocus: "",
required: true,
placeholder: "Enter Category name",
class: "rounded-lg w-full focus:ring-black focus:border-transparent placeholder:text-gray-500 pl-6" %>
</fieldset>
<fieldset>
<%= form.hidden_field :color, data: { color_select_target: "input" } %>
<ul role="radiogroup" class="flex justify-between items-center py-2">
<% Transaction::Category::COLORS.each do |color| %>
<li tabindex="0"
role="radio"
data-action="click->color-select#select keydown.enter->color-select#select keydown.space->color-select#select"
data-value="<%= color %>"
class="flex shrink-0 justify-center items-center w-5 h-5 cursor-pointer hover:bg-gray-200 rounded-full">
</li>
<% end %>
</ul>
</fieldset>
<section>
<%= hidden_field_tag :transaction_id, params[:transaction_id] %>
<% if category.persisted? %>
<%= form.submit t(".update") %>
<% else %>
<%= form.submit t(".create") %>
<% end %>
</section>
</div>
<% end %>

View file

@ -1,7 +1,7 @@
<%# locals: (transaction:) %>
<div class="relative" data-controller="menu">
<button data-menu-target="button cursor-pointer" class="flex">
<%= render partial: "transactions/categories/badge", locals: transaction.category.nil? ? {} : { name: transaction.category.name, color: transaction.category.color } %>
<%= render partial: "transactions/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-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
@ -22,16 +22,25 @@
<% end %>
</div>
<hr>
<div data-controller="menu" class="relative p-1.5 w-full">
<button data-menu-target="button" class="flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100">
<%= lucide_icon("plus", class: "w-5 h-5") %>
Add new
</button>
<div data-menu-target="content" class="hidden absolute bottom-14 right-0">
<div class="w-96 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= render partial: "transactions/categories/dropdown/form" %>
</div>
</div>
<div class="relative p-1.5 w-full">
<%= link_to new_transaction_category_path(transaction_id: transaction),
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
data: { turbo_frame: "modal" } do %>
<%= lucide_icon "plus", class: "w-5 h-5" %>
<%= t(".add_new") %>
<% end %>
<% if transaction.category %>
<%= button_to transaction_path(transaction),
method: :patch,
params: { transaction: { category_id: nil } },
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100" do %>
<%= lucide_icon "minus", class: "w-5 h-5" %>
<%= t(".clear") %>
<% end %>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,23 @@
<div class="flex justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
<%= render partial: "transactions/categories/badge", locals: { category: row } %>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to edit_transaction_category_path(row),
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<%= link_to new_transaction_category_deletion_path(row),
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>
<% end %>
</div>

View file

@ -0,0 +1,33 @@
<%= modal do %>
<article class="mx-auto p-4 w-screen max-w-md">
<div class="space-y-2">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".delete_category") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<p class="text-gray-500 font-light">
<%= t(".explanation", category_name: @category.name) %>
</p>
</div>
<%= form_with url: transaction_category_deletions_path(@category),
data: {
turbo: false,
controller: "category-deletion",
category_deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
category_deletion_safe_action_class: "form-field__submit border border-transparent",
category_deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
category_deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
<%= f.collection_select :replacement_category_id,
Current.family.transaction_categories.alphabetically.without(@category),
:id, :name,
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
{ data: { category_deletion_target: "replacementCategoryField", action: "category-deletion#updateSubmitButton" } } %>
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
data: { category_deletion_target: "submitButton" } %>
<% end %>
</article>
<% end %>

View file

@ -1,15 +0,0 @@
<%# locals: (category:) %>
<div class="w-96 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<div class="flex flex-col">
<%= render partial: "transactions/categories/dropdown/form", locals: { category: } %>
<hr>
<div class="p-1.5 w-full">
<%= button_to transactions_category_path(category),
method: :delete,
class: "flex text-sm font-medium items-center gap-2 text-red-600 w-full rounded-lg p-2 hover:bg-gray-100",
data: { turbo: false } do %>
<%= lucide_icon("trash-2", class: "w-5 h-5") %> Delete category
<% end %>
</div>
</div>
</div>

View file

@ -1,28 +0,0 @@
<%# locals: (category: nil) %>
<%= form_with url: category ? transactions_category_path(category) : transactions_categories_path, method: category ? :patch : :post, scope: :transaction_category, html: { class: "text-sm font-semibold leading-6 text-gray-900" }, data: { turbo: false } do |form| %>
<div class="flex flex-col">
<div class="flex flex-col p-1.5 gap-1.5">
<div class="relative flex items-center border border-gray-200 rounded-lg">
<%= form.text_field :name, value: category&.name, placeholder: "Enter Category name", class: "text-sm font-normal placeholder:text-gray-500 h-10 relative pl-6 w-full border-none rounded-lg" %>
</div>
<div class="p-2 overflow-x-auto">
<div data-controller="select" data-select-active-class="bg-gray-200" data-select-selected-value="<%= category&.color || Transaction::Category::COLORS[0] %>">
<%= form.hidden_field :color, data: { select_target: "input" } %>
<ul data-select-target="list" class="flex gap-2 items-center">
<% Transaction::Category::COLORS.each do |color| %>
<li tabindex="0" data-select-target="option" data-action="click->select#selectOption" data-value="<%= color %>" class="flex shrink-0 justify-center items-center w-6 h-6 cursor-pointer hover:bg-gray-200 rounded-full">
<div style="background-color: <%= color %>" class="shrink-0 w-4 h-4 rounded-full"></div>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<hr>
<div class="p-1.5 w-full">
<%= form.button "Create category", class: "flex text-sm font-medium items-center gap-2 text-gray-900 w-full rounded-lg p-2 hover:bg-gray-100" do %>
<%= lucide_icon("plus", class: "w-5 h-5") %> <%= category.nil? ? "Create" : "Update" %> category
<% end %>
</div>
</div>
<% end %>

View file

@ -1,18 +1,31 @@
<%# locals: (category:, transaction:) %>
<% is_selected = transaction.category_id == category.id %>
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
<%= button_to transaction_path(transaction, transaction: { category_id: category.id }), method: :patch, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<span class="w-5 h-5">
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
</span>
<%= render partial: "transactions/categories/badge", locals: { name: category.name, color: category.color } %>
<%= render partial: "transactions/categories/badge", locals: { category: category } %>
<% end %>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to edit_transaction_category_path(category),
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<%= link_to new_transaction_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 data-controller="menu">
<button data-menu-target="button" type="button" class="flex items-center justify-center hover:bg-gray-50 w-8 h-8 rounded-lg">
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %>
</button>
<div data-menu-target="content" class="absolute z-30 hidden w-screen mt-2 max-w-min">
<%= render partial: "transactions/categories/dropdown/edit", locals: { category: } %>
</div>
</div>
<% end %>
<% end %>

View file

@ -0,0 +1,10 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".edit") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", category: @category %>
</article>
<% end %>

View file

@ -1,15 +1,28 @@
<% content_for :sidebar do %>
<%= render "settings/nav" %>
<% end %>
<div class="space-y-4">
<h1 class="text-gray-900 text-xl font-medium mb-4">Categories</h1>
<section class="space-y-4">
<header class="flex items-center justify-between">
<h1 class="text-gray-900 text-xl font-medium"><%= t(".categories") %></h1>
<%= link_to new_transaction_category_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon "plus", class: "w-5 h-5" %>
<p><%= t(".new") %></p>
<% end %>
</header>
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
<div class="flex justify-center items-center py-20">
<p class="text-gray-500">Transaction categories coming soon...</p>
<div class="rounded-xl bg-gray-25 p-1">
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".categories") %> · <%= @categories.size %></h2>
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
<%= render collection: @categories, partial: "transactions/categories/row" %>
</div>
</div>
<div class="flex justify-between gap-4">
</div>
<footer class="flex justify-between gap-4">
<%= previous_setting("Accounts", accounts_path) %>
<%= next_setting("Merchants", transactions_merchants_path) %>
</div>
</div>
<%= next_setting("Merchants", transaction_merchants_path) %>
</footer>
</section>

View file

@ -0,0 +1,10 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".new_category") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", category: @category %>
</article>
<% end %>

View file

@ -3,6 +3,16 @@
<h1 class="text-xl">Transactions</h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to transaction_categories_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".edit_categories") %></span>
<% end %>
</div>
<% end %>
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p>New transaction</p>

View file

@ -1,6 +1,6 @@
<% is_editing = @merchant.id.present? %>
<div data-controller="merchant-avatar">
<%= form_with model: @merchant, url: is_editing ? transactions_merchant_path(@merchant) : transactions_merchants_path, method: is_editing ? :patch : :post, scope: :transaction_merchant, data: { turbo: false } do |f| %>
<%= form_with model: @merchant, url: is_editing ? transaction_merchant_path(@merchant) : transaction_merchants_path, method: is_editing ? :patch : :post, scope: :transaction_merchant, data: { turbo: false } do |f| %>
<section class="space-y-4">
<div class="w-fit m-auto">
<%= render partial: "transactions/merchants/avatar", locals: { merchant: } %>

View file

@ -13,13 +13,13 @@
</button>
<div data-menu-target="content" class="absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs w-48 hidden">
<div class="border-t border-b border-alpha-black-100 p-1">
<%= button_to edit_transactions_merchant_path(merchant),
<%= button_to edit_transaction_merchant_path(merchant),
method: :get,
class: "flex w-full gap-1 items-center text-sm hover:bg-gray-50 rounded-lg px-3 py-2",
data: { turbo_frame: "modal" } do %>
<%= lucide_icon("pencil-line", class: "w-5 h-5 mr-2") %> <%= t(".edit") %>
<% end %>
<%= button_to transactions_merchant_path(merchant),
<%= button_to transaction_merchant_path(merchant),
method: :delete,
class: "flex w-full gap-1 items-center text-sm text-red-600 hover:text-red-800 hover:bg-gray-50 rounded-lg px-3 py-2",
data: {

View file

@ -4,7 +4,7 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-medium text-gray-900"><%= t(".title") %></h1>
<%= link_to new_transactions_merchant_path, class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo_frame: "modal" } do %>
<%= link_to new_transaction_merchant_path, class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new_short") %></span>
<% end %>
@ -14,7 +14,7 @@
<div class="flex justify-center items-center py-20">
<div class="text-center flex flex-col items-center max-w-[300px]">
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".empty") %></p>
<%= link_to new_transactions_merchant_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo_frame: "modal" } do %>
<%= link_to new_transaction_merchant_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new_long") %></span>
<% end %>
@ -32,7 +32,7 @@
<% end %>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Categories", transactions_categories_path) %>
<%= next_setting("Rules", transactions_rules_path) %>
<%= previous_setting("Categories", transaction_categories_path) %>
<%= next_setting("Rules", transaction_rules_path) %>
</div>
</div>

View file

@ -1,5 +1,5 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4">
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
<header class="flex justify-between">
<h2 class="font-medium text-xl">New transaction</h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>

View file

@ -9,7 +9,7 @@
</div>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Merchants", transactions_merchants_path) %>
<%= previous_setting("Merchants", transaction_merchants_path) %>
<%= next_setting("What's New", changelog_path) %>
</div>
</div>

View file

@ -9,7 +9,7 @@
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= transaction_category.name %>">
<%= form.check_box :category_id_in, { "data-auto-submit-form-target": "auto", multiple: true, class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, transaction_category.id, nil %>
<%= form.label :category_id_in, transaction_category.name, value: transaction_category.id, class: "text-sm text-gray-900 cursor-pointer" do %>
<%= render partial: "transactions/categories/badge", locals: { name: transaction_category.name, color: transaction_category.color } %>
<%= render partial: "transactions/categories/badge", locals: { category: transaction_category } %>
<% end %>
</div>
<% end %>

View file

@ -3,12 +3,40 @@ en:
transactions:
categories:
create:
error: Error creating transaction category
success: New transaction category created successfully
destroy:
deletions:
create:
success: Transaction category deleted successfully
new:
category: Category
delete_and_leave_uncategorized: Delete "%{category_name}" and leave uncategorized
delete_and_recategorize: Delete "%{category_name}" and assign new category
delete_category: Delete category?
explanation: By deleting this category, every transaction that has the "%{category_name}"
category will be uncategorized. Instead of leaving them uncategorized,
you can also assign a new category below.
replacement_category_prompt: Select category
dropdown:
row:
delete: Delete category
edit: Edit category
edit:
edit: Edit category
form:
create: Create category
update: Update
index:
categories: Categories
new: New
menu:
add_new: Add new
clear: Clear
new:
new_category: New category
row:
delete: Delete category
edit: Edit category
update:
error: Error updating transaction category
success: Transaction category updated successfully
create:
success: New transaction created successfully
@ -27,6 +55,8 @@ en:
income: Income
submit: Add transaction
transfer: Transfer
index:
edit_categories: Edit categories
merchants:
create:
error: Error creating merchant

View file

@ -21,17 +21,19 @@ Rails.application.routes.draw do
resource :security, only: %i[show update]
end
namespace :transactions do
resources :categories
# TODO: These are *placeholders*
# Uncomment `only` and add the necessary actions as they are implemented.
resources :rules, only: [ :index ]
resources :merchants, only: %i[index new create edit update destroy]
end
resources :transactions do
match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search
collection do
scope module: :transactions do
resources :categories, as: :transaction_categories do
resources :deletions, only: %i[ new create ], module: :categories
end
resources :rules, only: %i[ index ], as: :transaction_rules
resources :merchants, only: %i[ index new create edit update destroy ], as: :transaction_merchants
end
end
end
resources :accounts, shallow: true do

View file

@ -0,0 +1,40 @@
require "test_helper"
class Transactions::Categories::DeletionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@category = transaction_categories(:food_and_drink)
end
test "new" do
get new_transaction_category_deletion_url(@category)
assert_response :success
end
test "create with replacement" do
replacement_category = transaction_categories(:income)
assert_not_empty @category.transactions
assert_difference "Transaction::Category.count", -1 do
assert_difference "replacement_category.transactions.count", @category.transactions.count do
post transaction_category_deletions_url(@category),
params: { replacement_category_id: replacement_category.id }
end
end
assert_redirected_to transactions_url
end
test "create without replacement" do
assert_not_empty @category.transactions
assert_difference "Transaction::Category.count", -1 do
assert_difference "Transaction.where(category: nil).count", @category.transactions.count do
post transaction_category_deletions_url(@category)
end
end
assert_redirected_to transactions_url
end
end

View file

@ -1,7 +1,73 @@
require "test_helper"
class Transactions::CategoriesControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
setup do
sign_in users(:family_admin)
end
test "index" do
get transaction_categories_url
assert_response :success
end
test "new" do
get new_transaction_category_url
assert_response :success
end
test "create" do
color = Transaction::Category::COLORS.sample
assert_difference "Transaction::Category.count", +1 do
post transaction_categories_url, params: {
transaction_category: {
name: "New Category",
color: color } }
end
new_category = Transaction::Category.order(:created_at).last
assert_redirected_to transactions_url
assert_equal "New Category", new_category.name
assert_equal color, new_category.color
end
test "create and assign to transaction" do
color = Transaction::Category::COLORS.sample
assert_difference "Transaction::Category.count", +1 do
post transaction_categories_url, params: {
transaction_id: transactions(:checking_one).id,
transaction_category: {
name: "New Category",
color: color } }
end
new_category = Transaction::Category.order(:created_at).last
assert_redirected_to transactions_url
assert_equal "New Category", new_category.name
assert_equal color, new_category.color
assert_equal transactions(:checking_one).reload.category, new_category
end
test "edit" do
get edit_transaction_category_url(transaction_categories(:food_and_drink))
assert_response :success
end
test "update" do
new_color = Transaction::Category::COLORS.without(transaction_categories(:income).color).sample
assert_changes -> { transaction_categories(:income).name }, to: "New Name" do
assert_changes -> { transaction_categories(:income).reload.color }, to: new_color do
patch transaction_category_url(transaction_categories(:income)), params: {
transaction_category: {
name: "New Name",
color: new_color } }
end
end
assert_redirected_to transactions_url
end
end

View file

@ -7,33 +7,33 @@ class Transactions::MerchantsControllerTest < ActionDispatch::IntegrationTest
end
test "index" do
get transactions_merchants_path
get transaction_merchants_path
assert_response :success
end
test "new" do
get new_transactions_merchant_path
get new_transaction_merchant_path
assert_response :success
end
test "should create merchant" do
assert_difference("Transaction::Merchant.count") do
post transactions_merchants_url, params: { transaction_merchant: { name: "new merchant", color: "#000000" } }
post transaction_merchants_url, params: { transaction_merchant: { name: "new merchant", color: "#000000" } }
end
assert_redirected_to transactions_merchants_path
assert_redirected_to transaction_merchants_path
end
test "should update merchant" do
patch transactions_merchant_url(@merchant), params: { transaction_merchant: { name: "new name", color: "#000000" } }
assert_redirected_to transactions_merchants_path
patch transaction_merchant_url(@merchant), params: { transaction_merchant: { name: "new name", color: "#000000" } }
assert_redirected_to transaction_merchants_path
end
test "should destroy merchant" do
assert_difference("Transaction::Merchant.count", -1) do
delete transactions_merchant_url(@merchant)
delete transaction_merchant_url(@merchant)
end
assert_redirected_to transactions_merchants_path
assert_redirected_to transaction_merchants_path
end
end

View file

@ -32,4 +32,20 @@ class Transaction::CategoryTest < ActiveSupport::TestCase
category.update_attribute(:color, "#000")
end
end
test "replacing and destroying" do
transctions = transaction_categories(:food_and_drink).transactions.to_a
transaction_categories(:food_and_drink).replace_and_destroy!(transaction_categories(:income))
assert_equal transaction_categories(:income), transactions.map { |t| t.reload.category }.uniq.first
end
test "replacing with nil should nullify the category" do
transactions = transaction_categories(:food_and_drink).transactions.to_a
transaction_categories(:food_and_drink).replace_and_destroy!(nil)
assert_nil transactions.map { |t| t.reload.category }.uniq.first
end
end

View file

@ -11,9 +11,9 @@ class SettingsTest < ApplicationSystemTestCase
[ "Security", "Security", settings_security_path ],
[ "Billing", "Billing", settings_billing_path ],
[ "Accounts", "Accounts", accounts_path ],
[ "Categories", "Categories", transactions_categories_path ],
[ "Merchants", "Merchants", transactions_merchants_path ],
[ "Rules", "Rules", transactions_rules_path ],
[ "Categories", "Categories", transaction_categories_path ],
[ "Merchants", "Merchants", transaction_merchants_path ],
[ "Rules", "Rules", transaction_rules_path ],
[ "What's New", "What's New", changelog_path ],
[ "Feedback", "Feedback", feedback_path ],
[ "Invite friends", "Invite friends", invites_path ]