From 6b49cdb3cadb12f48b2cba83286c4aa2ad13fc67 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 19 Dec 2024 18:58:26 -0500 Subject: [PATCH] Subcategory implementation --- app/controllers/categories_controller.rb | 17 +++- app/controllers/registrations_controller.rb | 1 - app/models/category.rb | 86 +++++++++++++------ app/models/demo/generator.rb | 5 ++ app/views/categories/_badge.html.erb | 2 +- app/views/categories/_category.html.erb | 6 +- app/views/categories/_form.html.erb | 13 ++- app/views/categories/_menu.html.erb | 2 +- app/views/categories/edit.html.erb | 2 +- app/views/categories/index.html.erb | 28 ++++-- app/views/categories/new.html.erb | 2 +- app/views/category/dropdowns/_row.html.erb | 3 + app/views/category/dropdowns/show.html.erb | 17 +++- config/locales/models/transaction/en.yml | 12 --- config/locales/views/categories/en.yml | 13 ++- config/routes.rb | 2 + .../20241219174803_add_parent_category.rb | 3 +- db/schema.rb | 3 +- .../controllers/categories_controller_test.rb | 14 ++- .../registrations_controller_test.rb | 9 -- test/fixtures/categories.yml | 7 +- test/models/category_test.rb | 38 +++----- 22 files changed, 178 insertions(+), 107 deletions(-) delete mode 100644 config/locales/models/transaction/en.yml diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index ddc1ecaae96..2d1882f44ae 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -10,6 +10,7 @@ def index def new @category = Current.family.categories.new color: Category::COLORS.sample + @categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id) end def create @@ -17,19 +18,21 @@ def create if @category.save @transaction.update(category_id: @category.id) if @transaction - redirect_back_or_to transactions_path, notice: t(".success") + + redirect_back_or_to categories_path, notice: t(".success") else - redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence) + render :new, status: :unprocessable_entity end end def edit + @categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id) end def update @category.update! category_params - redirect_back_or_to transactions_path, notice: t(".success") + redirect_back_or_to categories_path, notice: t(".success") end def destroy @@ -38,6 +41,12 @@ def destroy redirect_back_or_to categories_path, notice: t(".success") end + def bootstrap + Current.family.categories.bootstrap_defaults + + redirect_back_or_to categories_path, notice: t(".success") + end + private def set_category @category = Current.family.categories.find(params[:id]) @@ -50,6 +59,6 @@ def set_transaction end def category_params - params.require(:category).permit(:name, :color) + params.require(:category).permit(:name, :color, :parent_id) end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 128b309cb81..0d8d6e92674 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -24,7 +24,6 @@ def create if @user.save @invitation&.update!(accepted_at: Time.current) - Category.create_default_categories(@user.family) unless @invitation @session = create_session_for(@user) redirect_to root_path, notice: t(".success") else diff --git a/app/models/category.rb b/app/models/category.rb index 4a2d63614f8..6f50070bf91 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,12 +1,16 @@ class Category < ApplicationRecord has_many :transactions, dependent: :nullify, class_name: "Account::Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" + belongs_to :family + has_many :subcategories, class_name: "Category", foreign_key: :parent_id + belongs_to :parent, class_name: "Category", optional: true + validates :name, :color, :family, presence: true validates :name, uniqueness: { scope: :family_id } - before_update :clear_internal_category, if: :name_changed? + validate :category_level_limit scope :alphabetically, -> { order(:name) } @@ -14,30 +18,55 @@ class Category < ApplicationRecord UNCATEGORIZED_COLOR = "#737373" - DEFAULT_CATEGORIES = [ - { internal_category: "income", color: COLORS[0] }, - { internal_category: "food_and_drink", color: COLORS[1] }, - { internal_category: "entertainment", color: COLORS[2] }, - { internal_category: "personal_care", color: COLORS[3] }, - { internal_category: "general_services", color: COLORS[4] }, - { internal_category: "auto_and_transport", color: COLORS[5] }, - { internal_category: "rent_and_utilities", color: COLORS[6] }, - { internal_category: "home_improvement", color: COLORS[7] } - ] - - def self.create_default_categories(family) - if family.categories.size > 0 - raise ArgumentError, "Family already has some categories" + class Group + attr_reader :category, :subcategories + + delegate :name, :color, to: :category + + def self.for(categories) + categories.select { |category| category.parent_id.nil? }.map do |category| + new(category, category.subcategories) + end + end + + def initialize(category, subcategories = nil) + @category = category + @subcategories = subcategories || [] + end + end + + class << self + def bootstrap_defaults + default_categories.each do |name, color| + find_or_create_by!(name: name) do |category| + category.color = color + end + end end - family_id = family.id - categories = self::DEFAULT_CATEGORIES.map { |c| { - name: I18n.t("transaction.default_category.#{c[:internal_category]}"), - internal_category: c[:internal_category], - color: c[:color], - family_id: - } } - self.insert_all(categories) + private + def default_categories + [ + [ "Income", "#e99537" ], + [ "Loan Payments", "#6471eb" ], + [ "Bank Fees", "#db5a54" ], + [ "Entertainment", "#df4e92" ], + [ "Food & Drink", "#c44fe9" ], + [ "Groceries", "#eb5429" ], + [ "Dining Out", "#61c9ea" ], + [ "General Merchandise", "#805dee" ], + [ "Clothing & Accessories", "#6ad28a" ], + [ "Electronics", "#e99537" ], + [ "Healthcare", "#4da568" ], + [ "Insurance", "#6471eb" ], + [ "Utilities", "#db5a54" ], + [ "Transportation", "#df4e92" ], + [ "Gas & Fuel", "#c44fe9" ], + [ "Education", "#eb5429" ], + [ "Charitable Donations", "#61c9ea" ], + [ "Subscriptions", "#805dee" ] + ] + end end def replace_and_destroy!(replacement) @@ -47,9 +76,14 @@ def replace_and_destroy!(replacement) end end - private + def subcategory? + parent.present? + end - def clear_internal_category - self.internal_category = nil + private + def category_level_limit + if subcategory? && parent.subcategory? + errors.add(:parent, "can't have more than 2 levels of subcategories") + end end end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 36e86200a28..d26b85c7783 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -90,6 +90,11 @@ def create_categories! categories.each do |category| family.categories.create!(name: category, color: COLORS.sample) end + + food = family.categories.find_by(name: "Food & Drink") + family.categories.create!(name: "Restaurants", parent_category: food) + family.categories.create!(name: "Groceries", parent_category: food) + family.categories.create!(name: "Alcohol & Bars", parent_category: food) end def create_merchants! diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb index a9752262c9d..1b4c399b513 100644 --- a/app/views/categories/_badge.html.erb +++ b/app/views/categories/_badge.html.erb @@ -2,7 +2,7 @@ <% category ||= null_category %>
- " class="flex justify-between items-center p-4 bg-white"> +
<%= "pb-4" unless category.subcategories.any? %> bg-white">
+ <% if category.subcategory? %> + <%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-gray-400 ml-2" %> + <% end %> + <%= render partial: "categories/badge", locals: { category: category } %>
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 313e48ba417..2bca2191cf1 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -1,9 +1,12 @@ +<%# locals: (category:, categories:) %> +
<%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
+
<% Category::COLORS.each do |color| %> <% end %>
-
- <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, data: { color_avatar_target: "name" } %> + + <% if category.errors.any? %> + <%= render "shared/form_errors", model: category %> + <% end %> + +
+ <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %> + <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %>
diff --git a/app/views/categories/_menu.html.erb b/app/views/categories/_menu.html.erb index 746bcb459f1..5a4627b3e41 100644 --- a/app/views/categories/_menu.html.erb +++ b/app/views/categories/_menu.html.erb @@ -5,7 +5,7 @@ <%= render partial: "categories/badge", locals: { category: transaction.category } %>