Skip to content

Commit

Permalink
Subcategory implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
zachgoll committed Dec 19, 2024
1 parent 16d0556 commit 6b49cdb
Show file tree
Hide file tree
Showing 22 changed files with 178 additions and 107 deletions.
17 changes: 13 additions & 4 deletions app/controllers/categories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,29 @@ 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
@category = Current.family.categories.new(category_params)

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
Expand All @@ -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])
Expand All @@ -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
1 change: 0 additions & 1 deletion app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 60 additions & 26 deletions app/models/category.rb
Original file line number Diff line number Diff line change
@@ -1,43 +1,72 @@
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) }

COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]

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)
Expand All @@ -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
5 changes: 5 additions & 0 deletions app/models/demo/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
2 changes: 1 addition & 1 deletion app/views/categories/_badge.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<% category ||= null_category %>

<div>
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border"
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border truncate"
style="
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
border-color: color-mix(in srgb, <%= category.color %> 30%, white);
Expand Down
6 changes: 5 additions & 1 deletion app/views/categories/_category.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<%# locals: (category:) %>

<div id="<%= dom_id(category) %>" class="flex justify-between items-center p-4 bg-white">
<div id="<%= dom_id(category) %>" class="flex justify-between items-center px-4 pb-4 <%= "pt-4" unless category.subcategory? %> <%= "pb-4" unless category.subcategories.any? %> bg-white">
<div class="flex w-full items-center gap-2.5">
<% 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 } %>
</div>
<div class="justify-self-end">
Expand Down
13 changes: 11 additions & 2 deletions app/views/categories/_form.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<%# locals: (category:, categories:) %>

<div data-controller="color-avatar">
<%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
<section class="space-y-4">
<div class="w-fit m-auto">
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
</div>

<div class="flex gap-2 items-center justify-center">
<% Category::COLORS.each do |color| %>
<label class="relative">
Expand All @@ -12,8 +15,14 @@
</label>
<% end %>
</div>
<div class="relative flex items-center border border-gray-200 rounded-lg">
<%= 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 %>

<div class="space-y-2">
<%= 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)" } %>
</div>
</section>

Expand Down
2 changes: 1 addition & 1 deletion app/views/categories/_menu.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
</button>
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<div class="w-80 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
Expand Down
2 changes: 1 addition & 1 deletion app/views/categories/edit.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<%= modal_form_wrapper title: t(".edit") do %>
<%= render "form", category: @category %>
<%= render "form", category: @category, categories: @categories %>
<% end %>
28 changes: 21 additions & 7 deletions app/views/categories/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,32 @@

<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
<div class="overflow-hidden rounded-md">
<%= render partial: @categories, spacer_template: "categories/ruler" %>
<% Category::Group.for(@categories).each_with_index do |group, idx| %>
<%= render group.category %>

<% group.subcategories.each do |subcategory| %>
<%= render subcategory %>
<% end %>

<% unless idx == Category::Group.for(@categories).count - 1 %>
<%= render "categories/ruler" %>
<% end %>
<% end %>
</div>
</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_category_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 class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-gray-500 mb-4"><%= t(".empty") %></p>
<div class="flex items-center gap-2">
<%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--primary" %>

<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
</div>
</div>
</div>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/categories/new.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<%= modal_form_wrapper title: t(".new_category") do %>
<%= render "form", category: @category %>
<%= render "form", category: @category, categories: @categories %>
<% end %>
3 changes: 3 additions & 0 deletions app/views/category/dropdowns/_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
<span class="w-5 h-5">
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
</span>
<% if category.subcategory? %>
<%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-gray-400" %>
<% end %>
<%= render partial: "categories/badge", locals: { category: category } %>
<% end %>

Expand Down
17 changes: 15 additions & 2 deletions app/views/category/dropdowns/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,21 @@
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
<%= t(".no_categories") %>
</div>
<% @categories.each do |category| %>
<%= render partial: "category/dropdowns/row", locals: { category: } %>
<% if @categories.any? %>
<% Category::Group.for(@categories).each do |group| %>
<%= render "category/dropdowns/row", category: group.category %>

<% group.subcategories.each do |category| %>
<%= render "category/dropdowns/row", category: category %>
<% end %>
<% end %>
<% else %>
<div class="flex justify-center items-center py-12">
<div class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-gray-500 font-normal mb-4"><%= t(".empty") %></p>
<%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--outline", data: { turbo_frame: :_top } %>
</div>
</div>
<% end %>
</div>
<hr>
Expand Down
12 changes: 0 additions & 12 deletions config/locales/models/transaction/en.yml

This file was deleted.

13 changes: 10 additions & 3 deletions config/locales/views/categories/en.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
---
en:
category:
dropdowns:
show:
empty: No categories found
bootstrap: Generate default categories
categories:
category:
delete: Delete category
edit: Edit category
create:
failure: 'Failed to create category: %{error}'
success: New transaction category created successfully
success: Category created successfully
destroy:
success: Category deleted successfully
edit:
Expand All @@ -17,9 +21,12 @@ en:
categories: Categories
empty: No categories found
new: New category
bootstrap: Use default categories
bootstrap:
success: Default categories created successfully
menu:
loading: Loading...
new:
new_category: New category
update:
success: Transaction category updated successfully
success: Category updated successfully
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@

resources :categories do
resources :deletions, only: %i[new create], module: :category

post :bootstrap, on: :collection
end

resources :merchants, only: %i[index new create edit update destroy]
Expand Down
3 changes: 2 additions & 1 deletion db/migrate/20241219174803_add_parent_category.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class AddParentCategory < ActiveRecord::Migration[7.2]
def change
add_column :categories, :parent_category_id, :uuid
add_column :categories, :parent_id, :uuid
remove_column :categories, :internal_category, :string
end
end
Loading

0 comments on commit 6b49cdb

Please sign in to comment.