From d29d465a3cf1a7558aa4629df30203a0465ee804 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 4 Apr 2024 17:29:50 -0400 Subject: [PATCH] Basic transaction categories CRUD actions (inline) (#601) * Fix dropdown issues and add dummy transaction category modal * Minor namings tweaks * Add search type * Use new menu controller * Complete basic transaction category inline CRUD actions * Fix lint error --------- Co-authored-by: Jakub Kottnauer --- .../transactions/categories_controller.rb | 34 ++++++++++ app/helpers/transactions/categories_helper.rb | 2 + app/javascript/controllers/menu_controller.js | 25 +++++--- .../controllers/select_controller.js | 62 +++++++++++++++---- app/javascript/tailwindColors.js | 15 ----- app/models/transaction/category.rb | 22 ++++--- app/views/layouts/application.html.erb | 2 +- app/views/shared/_period_select.html.erb | 6 +- .../transactions/_category_dropdown.html.erb | 45 -------------- app/views/transactions/_search_form.html.erb | 2 +- app/views/transactions/_transaction.html.erb | 2 +- .../categories/_badge.html.erb} | 2 +- .../transactions/categories/_menu.html.erb | 39 ++++++++++++ .../categories/dropdown/_edit.html.erb | 15 +++++ .../categories/dropdown/_form.html.erb | 28 +++++++++ .../categories/dropdown/_row.html.erb | 18 ++++++ .../search_form/_category_filter.html.erb | 2 +- config/locales/views/transaction/en.yml | 9 +++ config/routes.rb | 4 ++ ...ge_transaction_category_delete_behavior.rb | 6 ++ db/schema.rb | 4 +- lib/tasks/demo_data.rake | 4 +- .../categories_controller_test.rb | 7 +++ 23 files changed, 254 insertions(+), 101 deletions(-) create mode 100644 app/controllers/transactions/categories_controller.rb create mode 100644 app/helpers/transactions/categories_helper.rb delete mode 100644 app/views/transactions/_category_dropdown.html.erb rename app/views/{shared/_category_badge.html.erb => transactions/categories/_badge.html.erb} (78%) create mode 100644 app/views/transactions/categories/_menu.html.erb create mode 100644 app/views/transactions/categories/dropdown/_edit.html.erb create mode 100644 app/views/transactions/categories/dropdown/_form.html.erb create mode 100644 app/views/transactions/categories/dropdown/_row.html.erb create mode 100644 db/migrate/20240404112829_change_transaction_category_delete_behavior.rb create mode 100644 test/controllers/transactions/categories_controller_test.rb diff --git a/app/controllers/transactions/categories_controller.rb b/app/controllers/transactions/categories_controller.rb new file mode 100644 index 00000000..9a1834b1 --- /dev/null +++ b/app/controllers/transactions/categories_controller.rb @@ -0,0 +1,34 @@ +class Transactions::CategoriesController < ApplicationController + before_action :set_category, only: [ :update, :destroy ] + + def create + if Current.family.transaction_categories.create(category_params) + redirect_to transactions_path, notice: t(".success") + else + render transactions_path, status: :unprocessable_entity, notice: t(".error") + end + end + + def update + if @category.update(category_params) + redirect_to transactions_path, notice: t(".success") + else + render transactions_path, status: :unprocessable_entity, notice: t(".error") + end + end + + def destroy + @category.destroy! + redirect_to transactions_path, notice: t(".success") + end + + private + + def set_category + @category = Current.family.transaction_categories.find(params[:id]) + end + + def category_params + params.require(:transaction_category).permit(:name, :name, :color) + end +end diff --git a/app/helpers/transactions/categories_helper.rb b/app/helpers/transactions/categories_helper.rb new file mode 100644 index 00000000..5ce10ffb --- /dev/null +++ b/app/helpers/transactions/categories_helper.rb @@ -0,0 +1,2 @@ +module Transactions::CategoriesHelper +end diff --git a/app/javascript/controllers/menu_controller.js b/app/javascript/controllers/menu_controller.js index 27a9d94e..3c31e880 100644 --- a/app/javascript/controllers/menu_controller.js +++ b/app/javascript/controllers/menu_controller.js @@ -6,11 +6,25 @@ import { Controller } from "@hotwired/stimulus"; * - If you need a form-enabled "select" element, use the "listbox" controller instead. */ export default class extends Controller { - static targets = ["button", "content"]; + static targets = [ + "button", + "content", + "submenu", + "submenuButton", + "submenuContent", + ]; + + static values = { + show: { type: Boolean, default: false }, + showSubmenu: { type: Boolean, default: false }, + }; + + initialize() { + this.show = this.showValue; + this.showSubmenu = this.showSubmenuValue; + } connect() { - this.show = false; - this.contentTarget.classList.add("hidden"); // Initially hide the popover this.buttonTarget.addEventListener("click", this.toggle); this.element.addEventListener("keydown", this.handleKeydown); document.addEventListener("click", this.handleOutsideClick); @@ -38,11 +52,6 @@ export default class extends Controller { handleKeydown = (event) => { switch (event.key) { - case " ": - event.preventDefault(); // Prevent the default action to avoid scrolling - if (document.activeElement === this.buttonTarget) { - this.toggle(); - } case "Escape": this.close(); this.buttonTarget.focus(); // Bring focus back to the button diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js index 04578728..4d8bb1a8 100644 --- a/app/javascript/controllers/select_controller.js +++ b/app/javascript/controllers/select_controller.js @@ -8,12 +8,25 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static classes = ["active"]; static targets = ["option", "button", "list", "input", "buttonText"]; + static values = { selected: String }; + + initialize() { + this.show = false; + + const selectedElement = this.optionTargets.find( + (option) => option.dataset.value === this.selectedValue + ); + if (selectedElement) { + this.updateAriaAttributesAndClasses(selectedElement); + this.syncButtonTextWithInput(); + } + } connect() { - this.show = false; this.syncButtonTextWithInput(); - this.listTarget.classList.add("hidden"); - this.buttonTarget.addEventListener("click", this.toggleList); + if (this.hasButtonTarget) { + this.buttonTarget.addEventListener("click", this.toggleList); + } this.element.addEventListener("keydown", this.handleKeydown); document.addEventListener("click", this.handleOutsideClick); this.element.addEventListener("turbo:load", this.handleTurboLoad); @@ -22,8 +35,15 @@ export default class extends Controller { disconnect() { this.element.removeEventListener("keydown", this.handleKeydown); document.removeEventListener("click", this.handleOutsideClick); - this.buttonTarget.removeEventListener("click", this.toggleList); this.element.removeEventListener("turbo:load", this.handleTurboLoad); + + if (this.hasButtonTarget) { + this.buttonTarget.removeEventListener("click", this.toggleList); + } + } + + selectedValueChanged() { + this.syncButtonTextWithInput(); } handleOutsideClick = (event) => { @@ -42,7 +62,10 @@ export default class extends Controller { case " ": case "Enter": event.preventDefault(); // Prevent the default action to avoid scrolling - if (document.activeElement === this.buttonTarget) { + if ( + this.hasButtonTarget && + document.activeElement === this.buttonTarget + ) { this.toggleList(); } else { this.selectOption(event); @@ -58,7 +81,9 @@ export default class extends Controller { break; case "Escape": this.close(); - this.buttonTarget.focus(); // Bring focus back to the button + if (this.hasButtonTarget) { + this.buttonTarget.focus(); // Bring focus back to the button + } break; case "Tab": this.close(); @@ -85,6 +110,8 @@ export default class extends Controller { } toggleList = () => { + if (!this.hasButtonTarget) return; // Ensure button target is present before toggling + this.show = !this.show; this.listTarget.classList.toggle("hidden", !this.show); this.buttonTarget.setAttribute("aria-expanded", this.show.toString()); @@ -99,14 +126,24 @@ export default class extends Controller { }; close() { - this.show = false; - this.listTarget.classList.add("hidden"); - this.buttonTarget.setAttribute("aria-expanded", "false"); + if (this.hasButtonTarget) { + this.show = false; + this.listTarget.classList.add("hidden"); + this.buttonTarget.setAttribute("aria-expanded", "false"); + } } selectOption(event) { const selectedOption = event.type === "keydown" ? document.activeElement : event.currentTarget; + this.updateAriaAttributesAndClasses(selectedOption); + if (this.inputTarget.value !== selectedOption.getAttribute("data-value")) { + this.updateInputValueAndEmitEvent(selectedOption); + } + this.close(); // Close the list after selection + } + + updateAriaAttributesAndClasses(selectedOption) { this.optionTargets.forEach((option) => { option.setAttribute("aria-selected", "false"); option.setAttribute("tabindex", "-1"); @@ -115,14 +152,15 @@ export default class extends Controller { selectedOption.classList.add(...this.activeClasses); selectedOption.setAttribute("aria-selected", "true"); selectedOption.focus(); - this.close(); // Close the list after selection + } + updateInputValueAndEmitEvent(selectedOption) { // Update the hidden input's value const selectedValue = selectedOption.getAttribute("data-value"); this.inputTarget.value = selectedValue; this.syncButtonTextWithInput(); - // Auto-submit controller listens for this even to auto-submit + // Emit an input event for auto-submit functionality const inputEvent = new Event("input", { bubbles: true, cancelable: true, @@ -134,7 +172,7 @@ export default class extends Controller { const matchingOption = this.optionTargets.find( (option) => option.getAttribute("data-value") === this.inputTarget.value ); - if (matchingOption) { + if (matchingOption && this.hasButtonTextTarget) { this.buttonTextTarget.textContent = matchingOption.textContent.trim(); } } diff --git a/app/javascript/tailwindColors.js b/app/javascript/tailwindColors.js index 709d5da0..5006b01d 100644 --- a/app/javascript/tailwindColors.js +++ b/app/javascript/tailwindColors.js @@ -3,21 +3,6 @@ * Stimulus controllers to reference our color palette. Mostly used for D3 charts. */ -export const categoryColors = [ - "#e99537", - "#4da568", - "#6471eb", - "#db5a54", - "#df4e92", - "#c44fe9", - "#eb5429", - "#61c9ea", - "#805dee", - "#6ad28a" -] - -export const categoryDefaultColor = "#737373" - export default { transparent: "transparent", current: "currentColor", diff --git a/app/models/transaction/category.rb b/app/models/transaction/category.rb index a33feb7a..614ec82b 100644 --- a/app/models/transaction/category.rb +++ b/app/models/transaction/category.rb @@ -1,20 +1,24 @@ class Transaction::Category < ApplicationRecord - has_many :transactions + has_many :transactions, dependent: :nullify belongs_to :family validates :name, :color, :family, presence: true before_update :clear_internal_category, if: :name_changed? + COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] + + UNCATEGORIZED_COLOR = "#737373" + DEFAULT_CATEGORIES = [ - { internal_category: "income", color: "#e99537" }, - { internal_category: "food_and_drink", color: "#4da568" }, - { internal_category: "entertainment", color: "#6471eb" }, - { internal_category: "personal_care", color: "#db5a54" }, - { internal_category: "general_services", color: "#df4e92" }, - { internal_category: "auto_and_transport", color: "#c44fe9" }, - { internal_category: "rent_and_utilities", color: "#eb5429" }, - { internal_category: "home_improvement", color: "#61c9ea" } + { 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.ransackable_attributes(auth_object = nil) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index fb6bfe73..64239432 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -37,7 +37,7 @@
<%= link_to edit_settings_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %> <%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %> diff --git a/app/views/shared/_period_select.html.erb b/app/views/shared/_period_select.html.erb index 0281ba62..f4260c51 100644 --- a/app/views/shared/_period_select.html.erb +++ b/app/views/shared/_period_select.html.erb @@ -1,11 +1,11 @@ -<%# locals: (value: 'all') -%> +<%# locals: (value: 'last_30_days') -%> <% options = [['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']] %> -
+
- +