Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create tagging system #792

Merged
merged 10 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/controllers/tags/deletions_controller.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions app/controllers/tags_controller.rb
Original file line number Diff line number Diff line change
@@ -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
19 changes: 14 additions & 5 deletions app/controllers/transactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ def edit

def create
@transaction = Current.family.accounts
.find(params[:transaction][:account_id])
.transactions.build(transaction_params.merge(amount: amount))
.find(params[:transaction][:account_id])
.transactions.build(transaction_params.merge(amount: amount))

respond_to do |format|
if @transaction.save
Expand All @@ -88,11 +88,20 @@ def create
def update
respond_to do |format|
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
@transaction.date
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)
@transaction.account.sync_later(sync_start_date)

Expand Down Expand Up @@ -121,6 +130,7 @@ def destroy
end

private

def delete_search_param(params, key, value: nil)
if value
params[key]&.delete(value)
Expand Down Expand Up @@ -153,8 +163,7 @@ def nature
params[:transaction][:nature].to_s.inquiry
end

# Only allow a list of trusted parameters through.
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
7 changes: 7 additions & 0 deletions app/helpers/tags_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module TagsHelper
def null_tag
Tag.new \
name: "Uncategorized",
color: Tag::UNCATEGORIZED_COLOR
end
end
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = [ "replacementCategoryField", "submitButton" ]
static targets = ["replacementField", "submitButton"]
static classes = [ "dangerousAction", "safeAction" ]
static values = {
submitTextWhenReplacing: String,
submitTextWhenNotReplacing: String
}

updateSubmitButton() {
if (this.replacementCategoryFieldTarget.value) {
if (this.replacementFieldTarget.value) {
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
this.#markSafe()
} else {
Expand Down
1 change: 1 addition & 0 deletions app/models/family.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Family < ApplicationRecord
has_many :users, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :transactions, through: :accounts
has_many :imports, through: :accounts
Expand Down
18 changes: 15 additions & 3 deletions app/models/import.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,24 @@ def update_csv(row_idx, col_idx, value)
def generate_transactions
transactions = []
category_cache = {}
tag_cache = {}

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 \
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
date: Date.iso8601(row["date"]),
category: category,
tags: tags,
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
currency: account.currency

Expand All @@ -144,12 +152,16 @@ def create_expected_fields
key: "category",
label: "Category"

tags_field = Import::Field.new \
key: "tags",
label: "Tags"

amount_field = Import::Field.new \
key: "amount",
label: "Amount",
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

def define_column_mapping_keys
Expand Down
25 changes: 25 additions & 0 deletions app/models/tag.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions app/models/tagging.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class Tagging < ApplicationRecord
belongs_to :tag
belongs_to :taggable, polymorphic: true
end
3 changes: 3 additions & 0 deletions app/models/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ class Transaction < ApplicationRecord
belongs_to :category, 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

monetize :amount
Expand Down
2 changes: 1 addition & 1 deletion app/views/accounts/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@
<% else %>
<%= previous_setting("Billing", settings_billing_path) %>
<% end %>
<%= next_setting("Categories", transaction_categories_path) %>
<%= next_setting("Tags", tags_path) %>
</div>
</div>
3 changes: 3 additions & 0 deletions app/views/settings/_nav.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
<div class="h-px bg-alpha-black-100 w-full"></div>
</div>
<ul class="space-y-1">
<li>
<%= sidebar_link_to t(".tags_label"), tags_path, icon: "tags" %>
</li>
<li>
<%= sidebar_link_to t(".categories_label"), transaction_categories_path, icon: "tags" %>
</li>
Expand Down
10 changes: 10 additions & 0 deletions app/views/tags/_badge.html.erb
Original file line number Diff line number Diff line change
@@ -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 changes: 38 additions & 0 deletions app/views/tags/_form.html.erb
Original file line number Diff line number Diff line change
@@ -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 changes: 23 additions & 0 deletions app/views/tags/_tag.html.erb
Original file line number Diff line number Diff line change
@@ -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 changes: 33 additions & 0 deletions app/views/tags/deletions/new.html.erb
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions app/views/tags/edit.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>
Loading