mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Create tagging system (#792)
* Repro * Fix * Update signage * Create tagging system * Add tags to transaction imports * Build tagging UI * Cleanup * More cleanup
This commit is contained in:
parent
41c991384a
commit
457247da8e
38 changed files with 607 additions and 90 deletions
24
app/controllers/tags/deletions_controller.rb
Normal file
24
app/controllers/tags/deletions_controller.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
class Tags::DeletionsController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
|
before_action :set_tag
|
||||||
|
before_action :set_replacement_tag, only: :create
|
||||||
|
|
||||||
|
def new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@tag.replace_and_destroy! @replacement_tag
|
||||||
|
redirect_back_or_to tags_path, notice: t(".deleted")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_tag
|
||||||
|
@tag = Current.family.tags.find_by(id: params[:tag_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_replacement_tag
|
||||||
|
@replacement_tag = Current.family.tags.find_by(id: params[:replacement_tag_id])
|
||||||
|
end
|
||||||
|
end
|
36
app/controllers/tags_controller.rb
Normal file
36
app/controllers/tags_controller.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
class TagsController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
|
before_action :set_tag, only: %i[ edit update ]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@tags = Current.family.tags.alphabetically
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@tag = Current.family.tags.new color: Tag::COLORS.sample
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
Current.family.tags.create!(tag_params)
|
||||||
|
redirect_to tags_path, notice: t(".created")
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@tag.update!(tag_params)
|
||||||
|
redirect_to tags_path, notice: t(".updated")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_tag
|
||||||
|
@tag = Current.family.tags.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_params
|
||||||
|
params.require(:tag).permit(:name, :color)
|
||||||
|
end
|
||||||
|
end
|
|
@ -72,8 +72,8 @@ class TransactionsController < ApplicationController
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@transaction = Current.family.accounts
|
@transaction = Current.family.accounts
|
||||||
.find(params[:transaction][:account_id])
|
.find(params[:transaction][:account_id])
|
||||||
.transactions.build(transaction_params.merge(amount: amount))
|
.transactions.build(transaction_params.merge(amount: amount))
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
if @transaction.save
|
if @transaction.save
|
||||||
|
@ -88,11 +88,20 @@ class TransactionsController < ApplicationController
|
||||||
def update
|
def update
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
sync_start_date = if transaction_params[:date]
|
sync_start_date = if transaction_params[:date]
|
||||||
[ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
|
[ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
|
||||||
else
|
else
|
||||||
@transaction.date
|
@transaction.date
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if params[:transaction][:tag_id].present?
|
||||||
|
tag = Current.family.tags.find(params[:transaction][:tag_id])
|
||||||
|
@transaction.tags << tag unless @transaction.tags.include?(tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:transaction][:remove_tag_id].present?
|
||||||
|
@transaction.tags.delete(params[:transaction][:remove_tag_id])
|
||||||
|
end
|
||||||
|
|
||||||
if @transaction.update(transaction_params)
|
if @transaction.update(transaction_params)
|
||||||
@transaction.account.sync_later(sync_start_date)
|
@transaction.account.sync_later(sync_start_date)
|
||||||
|
|
||||||
|
@ -121,6 +130,7 @@ class TransactionsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def delete_search_param(params, key, value: nil)
|
def delete_search_param(params, key, value: nil)
|
||||||
if value
|
if value
|
||||||
params[key]&.delete(value)
|
params[key]&.delete(value)
|
||||||
|
@ -153,8 +163,7 @@ class TransactionsController < ApplicationController
|
||||||
params[:transaction][:nature].to_s.inquiry
|
params[:transaction][:nature].to_s.inquiry
|
||||||
end
|
end
|
||||||
|
|
||||||
# Only allow a list of trusted parameters through.
|
|
||||||
def transaction_params
|
def transaction_params
|
||||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id)
|
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, :tag_id, :remove_tag_id).except(:tag_id, :remove_tag_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
7
app/helpers/tags_helper.rb
Normal file
7
app/helpers/tags_helper.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
module TagsHelper
|
||||||
|
def null_tag
|
||||||
|
Tag.new \
|
||||||
|
name: "Uncategorized",
|
||||||
|
color: Tag::UNCATEGORIZED_COLOR
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,7 +1,7 @@
|
||||||
import { Controller } from "@hotwired/stimulus";
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = [ "replacementCategoryField", "submitButton" ]
|
static targets = ["replacementField", "submitButton"]
|
||||||
static classes = [ "dangerousAction", "safeAction" ]
|
static classes = [ "dangerousAction", "safeAction" ]
|
||||||
static values = {
|
static values = {
|
||||||
submitTextWhenReplacing: String,
|
submitTextWhenReplacing: String,
|
||||||
|
@ -9,7 +9,7 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSubmitButton() {
|
updateSubmitButton() {
|
||||||
if (this.replacementCategoryFieldTarget.value) {
|
if (this.replacementFieldTarget.value) {
|
||||||
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
|
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
|
||||||
this.#markSafe()
|
this.#markSafe()
|
||||||
} else {
|
} else {
|
|
@ -1,5 +1,6 @@
|
||||||
class Family < ApplicationRecord
|
class Family < ApplicationRecord
|
||||||
has_many :users, dependent: :destroy
|
has_many :users, dependent: :destroy
|
||||||
|
has_many :tags, dependent: :destroy
|
||||||
has_many :accounts, dependent: :destroy
|
has_many :accounts, dependent: :destroy
|
||||||
has_many :transactions, through: :accounts
|
has_many :transactions, through: :accounts
|
||||||
has_many :imports, through: :accounts
|
has_many :imports, through: :accounts
|
||||||
|
|
|
@ -111,16 +111,24 @@ class Import < ApplicationRecord
|
||||||
def generate_transactions
|
def generate_transactions
|
||||||
transactions = []
|
transactions = []
|
||||||
category_cache = {}
|
category_cache = {}
|
||||||
|
tag_cache = {}
|
||||||
|
|
||||||
csv.table.each do |row|
|
csv.table.each do |row|
|
||||||
category_name = row["category"]
|
category_name = row["category"].presence
|
||||||
|
tag_strings = row["tags"].presence&.split("|") || []
|
||||||
|
tags = []
|
||||||
|
|
||||||
category = category_cache[category_name] ||= account.family.transaction_categories.find_or_initialize_by(name: category_name) if row["category"].present?
|
tag_strings.each do |tag_string|
|
||||||
|
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
|
||||||
|
end
|
||||||
|
|
||||||
|
category = category_cache[category_name] ||= account.family.transaction_categories.find_or_initialize_by(name: category_name)
|
||||||
|
|
||||||
txn = account.transactions.build \
|
txn = account.transactions.build \
|
||||||
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
||||||
date: Date.iso8601(row["date"]),
|
date: Date.iso8601(row["date"]),
|
||||||
category: category,
|
category: category,
|
||||||
|
tags: tags,
|
||||||
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
|
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
|
||||||
currency: account.currency
|
currency: account.currency
|
||||||
|
|
||||||
|
@ -144,12 +152,16 @@ class Import < ApplicationRecord
|
||||||
key: "category",
|
key: "category",
|
||||||
label: "Category"
|
label: "Category"
|
||||||
|
|
||||||
|
tags_field = Import::Field.new \
|
||||||
|
key: "tags",
|
||||||
|
label: "Tags"
|
||||||
|
|
||||||
amount_field = Import::Field.new \
|
amount_field = Import::Field.new \
|
||||||
key: "amount",
|
key: "amount",
|
||||||
label: "Amount",
|
label: "Amount",
|
||||||
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
|
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
|
||||||
|
|
||||||
[ date_field, name_field, category_field, amount_field ]
|
[ date_field, name_field, category_field, tags_field, amount_field ]
|
||||||
end
|
end
|
||||||
|
|
||||||
def define_column_mapping_keys
|
def define_column_mapping_keys
|
||||||
|
|
25
app/models/tag.rb
Normal file
25
app/models/tag.rb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
class Tag < ApplicationRecord
|
||||||
|
belongs_to :family
|
||||||
|
has_many :taggings, dependent: :destroy
|
||||||
|
has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction"
|
||||||
|
|
||||||
|
validates :name, presence: true, uniqueness: { scope: :family }
|
||||||
|
|
||||||
|
scope :alphabetically, -> { order(:name) }
|
||||||
|
|
||||||
|
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||||
|
|
||||||
|
UNCATEGORIZED_COLOR = "#737373"
|
||||||
|
|
||||||
|
def replace_and_destroy!(replacement)
|
||||||
|
transaction do
|
||||||
|
raise ActiveRecord::RecordInvalid, "Replacement tag cannot be the same as the tag being destroyed" if replacement == self
|
||||||
|
|
||||||
|
if replacement
|
||||||
|
taggings.update_all tag_id: replacement.id
|
||||||
|
end
|
||||||
|
|
||||||
|
destroy!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
4
app/models/tagging.rb
Normal file
4
app/models/tagging.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class Tagging < ApplicationRecord
|
||||||
|
belongs_to :tag
|
||||||
|
belongs_to :taggable, polymorphic: true
|
||||||
|
end
|
|
@ -5,6 +5,9 @@ class Transaction < ApplicationRecord
|
||||||
belongs_to :category, optional: true
|
belongs_to :category, optional: true
|
||||||
belongs_to :merchant, optional: true
|
belongs_to :merchant, optional: true
|
||||||
|
|
||||||
|
has_many :taggings, as: :taggable, dependent: :destroy
|
||||||
|
has_many :tags, through: :taggings
|
||||||
|
|
||||||
validates :name, :date, :amount, :account, presence: true
|
validates :name, :date, :amount, :account, presence: true
|
||||||
|
|
||||||
monetize :amount
|
monetize :amount
|
||||||
|
|
|
@ -63,6 +63,6 @@
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= previous_setting("Billing", settings_billing_path) %>
|
<%= previous_setting("Billing", settings_billing_path) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= next_setting("Categories", transaction_categories_path) %>
|
<%= next_setting("Tags", tags_path) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -46,6 +46,9 @@
|
||||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
<div class="h-px bg-alpha-black-100 w-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
|
<li>
|
||||||
|
<%= sidebar_link_to t(".tags_label"), tags_path, icon: "tags" %>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= sidebar_link_to t(".categories_label"), transaction_categories_path, icon: "tags" %>
|
<%= sidebar_link_to t(".categories_label"), transaction_categories_path, icon: "tags" %>
|
||||||
</li>
|
</li>
|
||||||
|
|
10
app/views/tags/_badge.html.erb
Normal file
10
app/views/tags/_badge.html.erb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<%# locals: (tag:) %>
|
||||||
|
<% tag ||= null_category %>
|
||||||
|
|
||||||
|
<span class="border text-sm font-medium px-2.5 py-1 rounded-full content-center"
|
||||||
|
style="
|
||||||
|
background-color: color-mix(in srgb, <%= tag.color %> 5%, white);
|
||||||
|
border-color: color-mix(in srgb, <%= tag.color %> 10%, white);
|
||||||
|
color: <%= tag.color %>;">
|
||||||
|
<%= tag.name %>
|
||||||
|
</span>
|
38
app/views/tags/_form.html.erb
Normal file
38
app/views/tags/_form.html.erb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<%= form_with model: tag, data: { turbo: false } do |form| %>
|
||||||
|
<div class="flex flex-col space-y-4 w-96" data-controller="color-select" data-color-select-selection-value="<%= tag.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: tag.name,
|
||||||
|
autofocus: "",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Enter tag 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">
|
||||||
|
<% Tag::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 :tag_id, params[:tag_id] %>
|
||||||
|
|
||||||
|
<% if tag.persisted? %>
|
||||||
|
<%= form.submit t(".update") %>
|
||||||
|
<% else %>
|
||||||
|
<%= form.submit t(".create") %>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
23
app/views/tags/_tag.html.erb
Normal file
23
app/views/tags/_tag.html.erb
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<div id="<%= dom_id(tag) %>" class="flex justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
|
||||||
|
<%= render "badge", tag: tag %>
|
||||||
|
|
||||||
|
<%= 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_tag_path(tag),
|
||||||
|
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_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 %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
33
app/views/tags/deletions/new.html.erb
Normal file
33
app/views/tags/deletions/new.html.erb
Normal 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_tag") %></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", tag_name: @tag.name) %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with url: tag_deletions_path(@tag),
|
||||||
|
data: {
|
||||||
|
turbo: false,
|
||||||
|
controller: "deletion",
|
||||||
|
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||||
|
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||||
|
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name),
|
||||||
|
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %>
|
||||||
|
<%= f.collection_select :replacement_tag_id,
|
||||||
|
Current.family.tags.alphabetically.without(@tag),
|
||||||
|
:id, :name,
|
||||||
|
{ prompt: t(".replacement_tag_prompt"), label: t(".tag") },
|
||||||
|
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
|
||||||
|
|
||||||
|
<%= f.submit t(".delete_and_leave_uncategorized", tag_name: @tag.name),
|
||||||
|
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||||
|
data: { deletion_target: "submitButton" } %>
|
||||||
|
<% end %>
|
||||||
|
</article>
|
||||||
|
<% end %>
|
10
app/views/tags/edit.html.erb
Normal file
10
app/views/tags/edit.html.erb
Normal 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", tag: @tag %>
|
||||||
|
</article>
|
||||||
|
<% end %>
|
49
app/views/tags/index.html.erb
Normal file
49
app/views/tags/index.html.erb
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<% content_for :sidebar do %>
|
||||||
|
<%= render "settings/nav" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
|
<h1 class="text-gray-900 text-xl font-medium"><%= t(".tags") %></h1>
|
||||||
|
|
||||||
|
<%= link_to new_tag_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">
|
||||||
|
|
||||||
|
<% if @tags.any? %>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-gray-25 p-1">
|
||||||
|
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".tags") %> · <%= @tags.size %></h2>
|
||||||
|
|
||||||
|
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
|
||||||
|
|
||||||
|
<%= render @tags %>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% else %>
|
||||||
|
|
||||||
|
<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_tag_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
|
||||||
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
|
<span><%= t(".new") %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="flex justify-between gap-4">
|
||||||
|
<%= previous_setting("Accounts", accounts_path) %>
|
||||||
|
<%= next_setting("Categories", transaction_categories_path) %>
|
||||||
|
</footer>
|
||||||
|
</section>
|
10
app/views/tags/new.html.erb
Normal file
10
app/views/tags/new.html.erb
Normal 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") %></h2>
|
||||||
|
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<%= render "form", tag: @tag %>
|
||||||
|
</article>
|
||||||
|
<% end %>
|
|
@ -14,20 +14,20 @@
|
||||||
<%= form_with url: transaction_category_deletions_path(@category),
|
<%= form_with url: transaction_category_deletions_path(@category),
|
||||||
data: {
|
data: {
|
||||||
turbo: false,
|
turbo: false,
|
||||||
controller: "category-deletion",
|
controller: "deletion",
|
||||||
category_deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
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",
|
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),
|
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| %>
|
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
|
||||||
<%= f.collection_select :replacement_category_id,
|
<%= f.collection_select :replacement_category_id,
|
||||||
Current.family.transaction_categories.alphabetically.without(@category),
|
Current.family.transaction_categories.alphabetically.without(@category),
|
||||||
:id, :name,
|
:id, :name,
|
||||||
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
|
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
|
||||||
{ data: { category_deletion_target: "replacementCategoryField", action: "category-deletion#updateSubmitButton" } } %>
|
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
|
||||||
|
|
||||||
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
|
<%= 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",
|
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||||
data: { category_deletion_target: "submitButton" } %>
|
data: { deletion_target: "submitButton" } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</article>
|
</article>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="flex justify-between gap-4">
|
<footer class="flex justify-between gap-4">
|
||||||
<%= previous_setting("Accounts", accounts_path) %>
|
<%= previous_setting("Tags", tags_path) %>
|
||||||
<%= next_setting("Merchants", transaction_merchants_path) %>
|
<%= next_setting("Merchants", transaction_merchants_path) %>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -4,37 +4,44 @@
|
||||||
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
|
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
|
||||||
</h3>
|
</h3>
|
||||||
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
|
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
|
||||||
<%= form_with model: @transaction, html: {data: {controller: "auto-submit-form"}} do |f| %>
|
|
||||||
<details class="group" open>
|
<details class="group" open>
|
||||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-4 group-open:mb-2">
|
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-4 group-open:mb-2">
|
||||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||||
Overview
|
Overview
|
||||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
<%= f.date_field :date, label: "Date", max: Date.today, "data-auto-submit-form-target": "auto" %>
|
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||||
<div class="h-2"></div>
|
<div class="space-y-2">
|
||||||
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-400" }, {class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled"} %>
|
<%= f.date_field :date, label: "Date", max: Date.today, "data-auto-submit-form-target": "auto" %>
|
||||||
</details>
|
<%= f.collection_select :category_id, Current.family.transaction_categories, :id, :name, { prompt: "Select a category", label: "Category", class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||||
<details class="group" open>
|
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
|
||||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
</div>
|
||||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
<% end %>
|
||||||
Description
|
</details>
|
||||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
<details class="group" open>
|
||||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||||
</div>
|
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||||
</summary>
|
Description
|
||||||
|
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||||
|
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||||
<%= f.text_field :name, label: "Name", "data-auto-submit-form-target": "auto" %>
|
<%= f.text_field :name, label: "Name", "data-auto-submit-form-target": "auto" %>
|
||||||
</details>
|
<% end %>
|
||||||
<details class="group" open>
|
</details>
|
||||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
<details class="group" open>
|
||||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||||
<span>Settings</span>
|
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
<span>Settings</span>
|
||||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||||
</div>
|
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||||
</summary>
|
</div>
|
||||||
|
</summary>
|
||||||
|
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||||
<label class="flex items-center cursor-pointer justify-between mx-3">
|
<label class="flex items-center cursor-pointer justify-between mx-3">
|
||||||
<%= f.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
|
<%= f.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
|
||||||
<div class="flex flex-col justify-center text-sm w-[340px] py-3">
|
<div class="flex flex-col justify-center text-sm w-[340px] py-3">
|
||||||
|
@ -43,16 +50,39 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-100 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-100 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
</label>
|
</label>
|
||||||
</details>
|
<% end %>
|
||||||
<details class="group" open>
|
</details>
|
||||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 mb-2">
|
<details class="group" open>
|
||||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 mb-2">
|
||||||
<span>Additional</span>
|
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
<span>Additional</span>
|
||||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||||
|
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
|
||||||
|
<% if @transaction.tags.any? %>
|
||||||
|
<div class="pt-3 pb-2 flex flex-wrap items-center gap-1">
|
||||||
|
<% @transaction.tags.each do |tag| %>
|
||||||
|
<div class="relative">
|
||||||
|
<%= render partial: "tags/badge", locals: { tag: tag } %>
|
||||||
|
<%= button_to transaction_path(@transaction, transaction: { remove_tag_id: tag.id }), method: :patch, "data-turbo": false, class: "absolute -top-2 -right-1 px-0.5 py rounded-full hover:bg-alpha-black-200 border border-alpha-black-100" do %>
|
||||||
|
<%= lucide_icon("x", class: "w-3 h-3") %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
<% end %>
|
||||||
|
|
||||||
|
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form", turbo: false } } do |f| %>
|
||||||
|
<%= f.collection_select :tag_id, Current.family.tags.alphabetically.excluding(@transaction.tags), :id, :name, { prompt: "Select a tag", label: "Select a tag", class: "placeholder:text-gray-500" }, "data-auto-submit-form-target": "auto", "data-turbo": false %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||||
<%= f.text_area :notes, label: "Notes", placeholder: "Enter a note", "data-auto-submit-form-target": "auto" %>
|
<%= f.text_area :notes, label: "Notes", placeholder: "Enter a note", "data-auto-submit-form-target": "auto" %>
|
||||||
</details>
|
<% end %>
|
||||||
<% end %>
|
</details>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -65,6 +65,7 @@ en:
|
||||||
rules_label: Rules
|
rules_label: Rules
|
||||||
security_label: Security
|
security_label: Security
|
||||||
self_hosting_label: Self-Hosting
|
self_hosting_label: Self-Hosting
|
||||||
|
tags_label: Tags
|
||||||
transactions_section_title: Transactions
|
transactions_section_title: Transactions
|
||||||
whats_new_label: What's New
|
whats_new_label: What's New
|
||||||
nav_link_large:
|
nav_link_large:
|
||||||
|
|
33
config/locales/views/tags/en.yml
Normal file
33
config/locales/views/tags/en.yml
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
---
|
||||||
|
en:
|
||||||
|
tags:
|
||||||
|
create:
|
||||||
|
created: Tag created
|
||||||
|
deletions:
|
||||||
|
create:
|
||||||
|
deleted: Tag deleted
|
||||||
|
new:
|
||||||
|
delete_and_leave_uncategorized: Delete "%{tag_name}"
|
||||||
|
delete_and_recategorize: Delete "%{tag_name}" and assign new tag
|
||||||
|
delete_tag: Delete tag?
|
||||||
|
explanation: "%{tag_name} will be removed from transactions and other taggable
|
||||||
|
entities. Instead of leaving them untagged, you can also assign a new tag
|
||||||
|
below."
|
||||||
|
replacement_tag_prompt: Select tag
|
||||||
|
tag: Tag
|
||||||
|
edit:
|
||||||
|
edit: Edit tag
|
||||||
|
form:
|
||||||
|
create: Create tag
|
||||||
|
update: Update
|
||||||
|
index:
|
||||||
|
empty: No tags yet
|
||||||
|
new: New tag
|
||||||
|
tags: Tags
|
||||||
|
new:
|
||||||
|
new: New tag
|
||||||
|
tag:
|
||||||
|
delete: Delete
|
||||||
|
edit: Edit
|
||||||
|
update:
|
||||||
|
updated: Tag updated
|
|
@ -37,6 +37,10 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :tags, except: %i[ show destroy ] do
|
||||||
|
resources :deletions, only: %i[ new create ], module: :tags
|
||||||
|
end
|
||||||
|
|
||||||
resources :transactions do
|
resources :transactions do
|
||||||
collection do
|
collection do
|
||||||
match "search" => "transactions#search", via: %i[ get post ]
|
match "search" => "transactions#search", via: %i[ get post ]
|
||||||
|
|
10
db/migrate/20240522133147_create_tags.rb
Normal file
10
db/migrate/20240522133147_create_tags.rb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
class CreateTags < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :tags, id: :uuid do |t|
|
||||||
|
t.string :name
|
||||||
|
t.string "color", default: "#e99537", null: false
|
||||||
|
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
9
db/migrate/20240522151453_create_taggings.rb
Normal file
9
db/migrate/20240522151453_create_taggings.rb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
class CreateTaggings < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :taggings, id: :uuid do |t|
|
||||||
|
t.references :tag, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.references :taggable, polymorphic: true, type: :uuid
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
25
db/schema.rb
generated
25
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2024_05_20_074309) do
|
ActiveRecord::Schema[7.2].define(version: 2024_05_22_151453) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -87,7 +87,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_05_20_074309) do
|
||||||
t.uuid "accountable_id"
|
t.uuid "accountable_id"
|
||||||
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
|
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
|
||||||
t.string "currency", default: "USD"
|
t.string "currency", default: "USD"
|
||||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Account::Loan'::character varying)::text, ('Account::Credit'::character varying)::text, ('Account::OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||||
t.boolean "is_active", default: true, null: false
|
t.boolean "is_active", default: true, null: false
|
||||||
t.enum "status", default: "ok", null: false, enum_type: "account_status"
|
t.enum "status", default: "ok", null: false, enum_type: "account_status"
|
||||||
t.jsonb "sync_warnings", default: "[]", null: false
|
t.jsonb "sync_warnings", default: "[]", null: false
|
||||||
|
@ -249,6 +249,25 @@ ActiveRecord::Schema[7.2].define(version: 2024_05_20_074309) do
|
||||||
t.index ["var"], name: "index_settings_on_var", unique: true
|
t.index ["var"], name: "index_settings_on_var", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "tag_id", null: false
|
||||||
|
t.string "taggable_type"
|
||||||
|
t.uuid "taggable_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["tag_id"], name: "index_taggings_on_tag_id"
|
||||||
|
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "tags", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "name"
|
||||||
|
t.string "color", default: "#e99537", null: false
|
||||||
|
t.uuid "family_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["family_id"], name: "index_tags_on_family_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "transaction_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "transaction_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "color", default: "#6172F3", null: false
|
t.string "color", default: "#6172F3", null: false
|
||||||
|
@ -318,6 +337,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_05_20_074309) do
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "imports", "accounts"
|
add_foreign_key "imports", "accounts"
|
||||||
|
add_foreign_key "taggings", "tags"
|
||||||
|
add_foreign_key "tags", "families"
|
||||||
add_foreign_key "transaction_categories", "families"
|
add_foreign_key "transaction_categories", "families"
|
||||||
add_foreign_key "transaction_merchants", "families"
|
add_foreign_key "transaction_merchants", "families"
|
||||||
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
||||||
|
|
|
@ -6,7 +6,10 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
setup do
|
setup do
|
||||||
sign_in @user = users(:family_admin)
|
sign_in @user = users(:family_admin)
|
||||||
@empty_import = imports(:empty_import)
|
@empty_import = imports(:empty_import)
|
||||||
@loaded_import = imports(:loaded_import)
|
|
||||||
|
@loaded_import = @empty_import.dup
|
||||||
|
@loaded_import.update! raw_csv_str: valid_csv_str
|
||||||
|
|
||||||
@completed_import = imports(:completed_import)
|
@completed_import = imports(:completed_import)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
36
test/controllers/tags/deletions_controller_test.rb
Normal file
36
test/controllers/tags/deletions_controller_test.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Tags::DeletionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
@user_tags = @user.family.tags
|
||||||
|
@tag = tags(:hawaii_trip)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get new" do
|
||||||
|
get new_tag_deletion_url(@tag)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create with replacement" do
|
||||||
|
replacement_tag = tags(:trips)
|
||||||
|
|
||||||
|
affected_transaction_count = @tag.transactions.count
|
||||||
|
|
||||||
|
assert affected_transaction_count > 0
|
||||||
|
|
||||||
|
assert_difference -> { Tag.count } => -1, -> { replacement_tag.transactions.count } => affected_transaction_count do
|
||||||
|
post tag_deletions_url(@tag), params: { replacement_tag_id: replacement_tag.id }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create without replacement" do
|
||||||
|
affected_transactions = @tag.transactions
|
||||||
|
|
||||||
|
assert affected_transactions.count > 0
|
||||||
|
|
||||||
|
assert_difference -> { Tag.count } => -1, -> { Tagging.count } => affected_transactions.count * -1 do
|
||||||
|
post tag_deletions_url(@tag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
42
test/controllers/tags_controller_test.rb
Normal file
42
test/controllers/tags_controller_test.rb
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TagsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get index" do
|
||||||
|
get tags_url
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
@user.family.tags.each do |tag|
|
||||||
|
assert_select "#" + dom_id(tag), count: 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get new" do
|
||||||
|
get new_tag_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should create tag" do
|
||||||
|
assert_difference("Tag.count") do
|
||||||
|
post tags_url, params: { tag: { name: "Test Tag" } }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to tags_url
|
||||||
|
assert_equal "Tag created", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get edit" do
|
||||||
|
get edit_tag_url(tags.first)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should update tag" do
|
||||||
|
patch tag_url(tags.first), params: { tag: { name: "Test Tag" } }
|
||||||
|
|
||||||
|
assert_redirected_to tags_url
|
||||||
|
assert_equal "Tag updated", flash[:notice]
|
||||||
|
end
|
||||||
|
end
|
24
test/fixtures/imports.yml
vendored
24
test/fixtures/imports.yml
vendored
|
@ -2,22 +2,6 @@ empty_import:
|
||||||
account: checking
|
account: checking
|
||||||
created_at: <%= 1.minute.ago %>
|
created_at: <%= 1.minute.ago %>
|
||||||
|
|
||||||
loaded_import:
|
|
||||||
account: checking
|
|
||||||
raw_csv_str: |
|
|
||||||
date,name,category,amount
|
|
||||||
2024-01-01,Starbucks drink,Food & Drink,-8.55
|
|
||||||
2024-01-01,Etsy,Shopping,-80.98
|
|
||||||
2024-01-02,Amazon stuff,Shopping,-200
|
|
||||||
2024-01-03,Paycheck,Income,1000
|
|
||||||
normalized_csv_str: |
|
|
||||||
date,name,category,amount
|
|
||||||
2024-01-01,Starbucks drink,Food & Drink,-8.55
|
|
||||||
2024-01-01,Etsy,Shopping,-80.98
|
|
||||||
2024-01-02,Amazon stuff,Shopping,-200
|
|
||||||
2024-01-03,Paycheck,Income,1000
|
|
||||||
created_at: <%= 2.days.ago %>
|
|
||||||
|
|
||||||
completed_import:
|
completed_import:
|
||||||
account: checking
|
account: checking
|
||||||
column_mappings:
|
column_mappings:
|
||||||
|
@ -26,11 +10,11 @@ completed_import:
|
||||||
category: category
|
category: category
|
||||||
amount: amount
|
amount: amount
|
||||||
raw_csv_str: |
|
raw_csv_str: |
|
||||||
date,name,category,amount
|
date,name,category,tags,amount
|
||||||
2024-01-01,Starbucks drink,Food & Drink,-20
|
2024-01-01,Starbucks drink,Food & Drink,Test Tag,-20
|
||||||
normalized_csv_str: |
|
normalized_csv_str: |
|
||||||
date,name,category,amount
|
date,name,category,tags,amount
|
||||||
2024-01-01,Starbucks drink,Food & Drink,-20
|
2024-01-01,Starbucks drink,Food & Drink,Test Tag,-20
|
||||||
created_at: <%= 2.days.ago %>
|
created_at: <%= 2.days.ago %>
|
||||||
|
|
||||||
|
|
||||||
|
|
10
test/fixtures/taggings.yml
vendored
Normal file
10
test/fixtures/taggings.yml
vendored
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
one:
|
||||||
|
tag: hawaii_trip
|
||||||
|
taggable: checking_one
|
||||||
|
taggable_type: Transaction
|
||||||
|
|
||||||
|
two:
|
||||||
|
tag: emergency_fund
|
||||||
|
taggable: checking_two
|
||||||
|
taggable_type: Transaction
|
||||||
|
|
11
test/fixtures/tags.yml
vendored
Normal file
11
test/fixtures/tags.yml
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
trips:
|
||||||
|
name: Trips
|
||||||
|
family: dylan_family
|
||||||
|
|
||||||
|
hawaii_trip:
|
||||||
|
name: Hawaii Trip
|
||||||
|
family: dylan_family
|
||||||
|
|
||||||
|
emergency_fund:
|
||||||
|
name: Emergency Fund
|
||||||
|
family: dylan_family
|
|
@ -5,7 +5,9 @@ class ImportTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@empty_import = imports(:empty_import)
|
@empty_import = imports(:empty_import)
|
||||||
@loaded_import = imports(:loaded_import)
|
|
||||||
|
@loaded_import = @empty_import.dup
|
||||||
|
@loaded_import.update! raw_csv_str: valid_csv_str
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raw csv input must conform to csv spec" do
|
test "raw csv input must conform to csv spec" do
|
||||||
|
@ -42,7 +44,11 @@ class ImportTest < ActiveSupport::TestCase
|
||||||
# Import has 3 unique categories: "Food & Drink", "Income", and "Shopping" (x2)
|
# Import has 3 unique categories: "Food & Drink", "Income", and "Shopping" (x2)
|
||||||
# Fixtures already define "Food & Drink" and "Income", so these should not be created
|
# Fixtures already define "Food & Drink" and "Income", so these should not be created
|
||||||
# "Shopping" is a new category, but should only be created 1x during import
|
# "Shopping" is a new category, but should only be created 1x during import
|
||||||
assert_difference -> { Transaction.count } => 4, -> { Transaction::Category.count } => 1 do
|
assert_difference \
|
||||||
|
-> { Transaction.count } => 4,
|
||||||
|
-> { Transaction::Category.count } => 1,
|
||||||
|
-> { Tagging.count } => 4,
|
||||||
|
-> { Tag.count } => 2 do
|
||||||
@loaded_import.publish
|
@loaded_import.publish
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
18
test/models/tag_test.rb
Normal file
18
test/models/tag_test.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TagTest < ActiveSupport::TestCase
|
||||||
|
test "replace and destroy" do
|
||||||
|
old_tag = tags(:hawaii_trip)
|
||||||
|
new_tag = tags(:trips)
|
||||||
|
|
||||||
|
assert_difference "Tag.count", -1 do
|
||||||
|
old_tag.replace_and_destroy!(new_tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
old_tag.transactions.each do |txn|
|
||||||
|
txn.reload
|
||||||
|
assert_includes txn.tags, new_tag
|
||||||
|
assert_not_includes txn.tags, old_tag
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,17 +1,18 @@
|
||||||
module ImportTestHelper
|
module ImportTestHelper
|
||||||
def valid_csv_str
|
def valid_csv_str
|
||||||
<<-ROWS
|
<<-ROWS
|
||||||
date,name,category,amount
|
date,name,category,tags,amount
|
||||||
2024-01-01,Starbucks drink,Food,-20
|
2024-01-01,Starbucks drink,Food & Drink,Tag1|Tag2,-8.55
|
||||||
2024-01-02,Amazon stuff,Shopping,-200
|
2024-01-01,Etsy,Shopping,Tag1,-80.98
|
||||||
2024-01-03,Paycheck,Income,1000
|
2024-01-02,Amazon stuff,Shopping,Tag2,-200
|
||||||
|
2024-01-03,Paycheck,Income,,1000
|
||||||
ROWS
|
ROWS
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_csv_with_invalid_values
|
def valid_csv_with_invalid_values
|
||||||
<<-ROWS
|
<<-ROWS
|
||||||
date,name,category,amount
|
date,name,category,tags,amount
|
||||||
invalid_date,Starbucks drink,Food,invalid_amount
|
invalid_date,Starbucks drink,Food,,invalid_amount
|
||||||
ROWS
|
ROWS
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ class SettingsTest < ApplicationSystemTestCase
|
||||||
[ "Security", "Security", settings_security_path ],
|
[ "Security", "Security", settings_security_path ],
|
||||||
[ "Billing", "Billing", settings_billing_path ],
|
[ "Billing", "Billing", settings_billing_path ],
|
||||||
[ "Accounts", "Accounts", accounts_path ],
|
[ "Accounts", "Accounts", accounts_path ],
|
||||||
|
[ "Tags", "Tags", tags_path ],
|
||||||
[ "Categories", "Categories", transaction_categories_path ],
|
[ "Categories", "Categories", transaction_categories_path ],
|
||||||
[ "Merchants", "Merchants", transaction_merchants_path ],
|
[ "Merchants", "Merchants", transaction_merchants_path ],
|
||||||
[ "Rules", "Rules", transaction_rules_path ],
|
[ "Rules", "Rules", transaction_rules_path ],
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue