From 457247da8e40254c469a1133fefbba4ffc5013b5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 23 May 2024 08:09:33 -0400 Subject: [PATCH] Create tagging system (#792) * Repro * Fix * Update signage * Create tagging system * Add tags to transaction imports * Build tagging UI * Cleanup * More cleanup --- app/controllers/tags/deletions_controller.rb | 24 ++++ app/controllers/tags_controller.rb | 36 ++++++ app/controllers/transactions_controller.rb | 19 ++- app/helpers/tags_helper.rb | 7 ++ ...n_controller.js => deletion_controller.js} | 4 +- app/models/family.rb | 1 + app/models/import.rb | 18 ++- app/models/tag.rb | 25 ++++ app/models/tagging.rb | 4 + app/models/transaction.rb | 3 + app/views/accounts/index.html.erb | 2 +- app/views/settings/_nav.html.erb | 3 + app/views/tags/_badge.html.erb | 10 ++ app/views/tags/_form.html.erb | 38 ++++++ app/views/tags/_tag.html.erb | 23 ++++ app/views/tags/deletions/new.html.erb | 33 ++++++ app/views/tags/edit.html.erb | 10 ++ app/views/tags/index.html.erb | 49 ++++++++ app/views/tags/new.html.erb | 10 ++ .../categories/deletions/new.html.erb | 14 +-- .../transactions/categories/index.html.erb | 2 +- app/views/transactions/show.html.erb | 110 +++++++++++------- config/locales/views/settings/en.yml | 1 + config/locales/views/tags/en.yml | 33 ++++++ config/routes.rb | 4 + db/migrate/20240522133147_create_tags.rb | 10 ++ db/migrate/20240522151453_create_taggings.rb | 9 ++ db/schema.rb | 25 +++- test/controllers/imports_controller_test.rb | 5 +- .../tags/deletions_controller_test.rb | 36 ++++++ test/controllers/tags_controller_test.rb | 42 +++++++ test/fixtures/imports.yml | 24 +--- test/fixtures/taggings.yml | 10 ++ test/fixtures/tags.yml | 11 ++ test/models/import_test.rb | 10 +- test/models/tag_test.rb | 18 +++ test/support/import_test_helper.rb | 13 ++- test/system/settings_test.rb | 1 + 38 files changed, 607 insertions(+), 90 deletions(-) create mode 100644 app/controllers/tags/deletions_controller.rb create mode 100644 app/controllers/tags_controller.rb create mode 100644 app/helpers/tags_helper.rb rename app/javascript/controllers/{category_deletion_controller.js => deletion_controller.js} (87%) create mode 100644 app/models/tag.rb create mode 100644 app/models/tagging.rb create mode 100644 app/views/tags/_badge.html.erb create mode 100644 app/views/tags/_form.html.erb create mode 100644 app/views/tags/_tag.html.erb create mode 100644 app/views/tags/deletions/new.html.erb create mode 100644 app/views/tags/edit.html.erb create mode 100644 app/views/tags/index.html.erb create mode 100644 app/views/tags/new.html.erb create mode 100644 config/locales/views/tags/en.yml create mode 100644 db/migrate/20240522133147_create_tags.rb create mode 100644 db/migrate/20240522151453_create_taggings.rb create mode 100644 test/controllers/tags/deletions_controller_test.rb create mode 100644 test/controllers/tags_controller_test.rb create mode 100644 test/fixtures/taggings.yml create mode 100644 test/fixtures/tags.yml create mode 100644 test/models/tag_test.rb diff --git a/app/controllers/tags/deletions_controller.rb b/app/controllers/tags/deletions_controller.rb new file mode 100644 index 00000000..8916ce74 --- /dev/null +++ b/app/controllers/tags/deletions_controller.rb @@ -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 diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 00000000..9a3ccebf --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -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 diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index ad2cc6e1..55cc84ce 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -72,8 +72,8 @@ class TransactionsController < ApplicationController 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 @@ -88,11 +88,20 @@ class TransactionsController < ApplicationController 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) @@ -121,6 +130,7 @@ class TransactionsController < ApplicationController end private + def delete_search_param(params, key, value: nil) if value params[key]&.delete(value) @@ -153,8 +163,7 @@ class TransactionsController < ApplicationController 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 diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb new file mode 100644 index 00000000..4a796243 --- /dev/null +++ b/app/helpers/tags_helper.rb @@ -0,0 +1,7 @@ +module TagsHelper + def null_tag + Tag.new \ + name: "Uncategorized", + color: Tag::UNCATEGORIZED_COLOR + end +end diff --git a/app/javascript/controllers/category_deletion_controller.js b/app/javascript/controllers/deletion_controller.js similarity index 87% rename from app/javascript/controllers/category_deletion_controller.js rename to app/javascript/controllers/deletion_controller.js index a07b4619..af3f0b04 100644 --- a/app/javascript/controllers/category_deletion_controller.js +++ b/app/javascript/controllers/deletion_controller.js @@ -1,7 +1,7 @@ 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, @@ -9,7 +9,7 @@ export default class extends Controller { } updateSubmitButton() { - if (this.replacementCategoryFieldTarget.value) { + if (this.replacementFieldTarget.value) { this.submitButtonTarget.value = this.submitTextWhenReplacingValue this.#markSafe() } else { diff --git a/app/models/family.rb b/app/models/family.rb index e3e174b1..88c9b022 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 diff --git a/app/models/import.rb b/app/models/import.rb index d545b074..6887103b 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -111,16 +111,24 @@ class Import < ApplicationRecord 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 @@ -144,12 +152,16 @@ class Import < ApplicationRecord 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 diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 00000000..6dd5988c --- /dev/null +++ b/app/models/tag.rb @@ -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 diff --git a/app/models/tagging.rb b/app/models/tagging.rb new file mode 100644 index 00000000..e608dcdc --- /dev/null +++ b/app/models/tagging.rb @@ -0,0 +1,4 @@ +class Tagging < ApplicationRecord + belongs_to :tag + belongs_to :taggable, polymorphic: true +end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 770547ea..c9b9508c 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -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 diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index a940c82a..914dcfe7 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -63,6 +63,6 @@ <% else %> <%= previous_setting("Billing", settings_billing_path) %> <% end %> - <%= next_setting("Categories", transaction_categories_path) %> + <%= next_setting("Tags", tags_path) %> diff --git a/app/views/settings/_nav.html.erb b/app/views/settings/_nav.html.erb index 2741bc87..3c283f9e 100644 --- a/app/views/settings/_nav.html.erb +++ b/app/views/settings/_nav.html.erb @@ -46,6 +46,9 @@