1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 21:29:38 +02:00

Ensure Consistent Category Colors (#1722)
Some checks are pending
Publish Docker image / ci (push) Waiting to run
Publish Docker image / Build docker image (push) Blocked by required conditions

* feat: add validation to require consistent category color

* feat: reflect color requirement in new category form

* refactor: move logic inline over shared component

* rubocop

* tests: fix breaking and add case for new validation

* feat: hide color selector when parent category selected

* feat: override color with parent color in model

* tests: remove case for unnecessary validation

---------

Signed-off-by: Julien Bertazzo Lambert <42924425+JLambertazzo@users.noreply.github.com>
This commit is contained in:
Julien Bertazzo Lambert 2025-01-30 16:49:31 -05:00 committed by GitHub
parent ded42a8c33
commit ad5b0b8b7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 19 additions and 4 deletions

View file

@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="color-avatar" // Connects to data-controller="color-avatar"
// Used by the transaction merchant form to show a preview of what the avatar will look like // Used by the transaction merchant form to show a preview of what the avatar will look like
export default class extends Controller { export default class extends Controller {
static targets = ["name", "avatar"]; static targets = ["name", "avatar", "selection"];
connect() { connect() {
this.nameTarget.addEventListener("input", this.handleNameChange); this.nameTarget.addEventListener("input", this.handleNameChange);
@ -25,4 +25,10 @@ export default class extends Controller {
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`; this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.color = color; this.avatarTarget.style.color = color;
} }
handleParentChange(e) {
const parent = e.currentTarget.value;
const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible"
this.selectionTarget.style.visibility = visibility
}
} }

View file

@ -14,6 +14,8 @@ class Category < ApplicationRecord
validate :category_level_limit validate :category_level_limit
validate :nested_category_matches_parent_classification validate :nested_category_matches_parent_classification
before_create :inherit_color_from_parent
scope :alphabetically, -> { order(:name) } scope :alphabetically, -> { order(:name) }
scope :roots, -> { where(parent_id: nil) } scope :roots, -> { where(parent_id: nil) }
scope :incomes, -> { where(classification: "income") } scope :incomes, -> { where(classification: "income") }
@ -85,6 +87,12 @@ class Category < ApplicationRecord
end end
end end
def inherit_color_from_parent
if subcategory?
self.color = parent.color
end
end
def replace_and_destroy!(replacement) def replace_and_destroy!(replacement)
transaction do transaction do
transactions.update_all category_id: replacement&.id transactions.update_all category_id: replacement&.id

View file

@ -7,7 +7,7 @@
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %> <%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
</div> </div>
<div class="flex gap-2 items-center justify-center"> <div class="flex gap-2 items-center justify-center" data-color-avatar-target="selection">
<% Category::COLORS.each do |color| %> <% Category::COLORS.each do |color| %>
<label class="relative"> <label class="relative">
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %> <%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %>
@ -21,6 +21,7 @@
<% end %> <% end %>
<div class="flex flex-wrap gap-2 justify-center mb-4"> <div class="flex flex-wrap gap-2 justify-center mb-4">
<% Category.icon_codes.each do |icon| %> <% Category.icon_codes.each do |icon| %>
<label class="relative"> <label class="relative">
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer" %> <%= f.radio_button :lucide_icon, icon, class: "sr-only peer" %>
@ -35,7 +36,7 @@
<%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %> <%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %>
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %> <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
<% unless category.parent? %> <% unless category.parent? %>
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent? %> <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->color-avatar#handleParentChange" } %>
<% end %> <% end %>
</div> </div>
</section> </section>

View file

@ -25,7 +25,7 @@ class CategoryTest < ActiveSupport::TestCase
category = categories(:subcategory) category = categories(:subcategory)
error = assert_raises(ActiveRecord::RecordInvalid) do error = assert_raises(ActiveRecord::RecordInvalid) do
category.subcategories.create!(name: "Invalid category", color: "#000", family: @family) category.subcategories.create!(name: "Invalid category", family: @family)
end end
assert_equal "Validation failed: Parent can't have more than 2 levels of subcategories", error.message assert_equal "Validation failed: Parent can't have more than 2 levels of subcategories", error.message