Skip to content

Commit

Permalink
Create tagging system (#792)
Browse files Browse the repository at this point in the history
* Repro

* Fix

* Update signage

* Create tagging system

* Add tags to transaction imports

* Build tagging UI

* Cleanup

* More cleanup
  • Loading branch information
zachgoll authored May 23, 2024
1 parent 41c9913 commit 457247d
Show file tree
Hide file tree
Showing 38 changed files with 607 additions and 90 deletions.
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

0 comments on commit 457247d

Please sign in to comment.