diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml index 2df3d98d9..6ad6c6b5b 100644 --- a/.github/workflows/preview-cleanup.yml +++ b/.github/workflows/preview-cleanup.yml @@ -88,7 +88,7 @@ jobs: cleanup-expired: name: Cleanup expired previews - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + if: github.repository == 'we-promise/sure' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/app/components/DS/disclosure.html.erb b/app/components/DS/disclosure.html.erb index 8cd25bcfc..007b09ac9 100644 --- a/app/components/DS/disclosure.html.erb +++ b/app/components/DS/disclosure.html.erb @@ -1,7 +1,7 @@ <%= tag.details class: details_classes, open: open, **details_opts do %> - <%= tag.summary class: summary_classes do %> + <%= tag.summary class: summary_classes, aria: summary_aria_attrs do %> <% if summary_content? %> - <% if variant == :default %> + <% if variant.in?(%i[default bare]) %> <%# `:default` summary is already `flex justify-between`, so caller-provided sibling divs get distributed directly. Wrapping would collapse them into a single flex child and @@ -33,7 +33,11 @@ <% end %> <% end %> - <%= tag.div class: body_class.presence do %> + <% if variant == :bare %> <%= content %> + <% else %> + <%= tag.div class: body_class.presence do %> + <%= content %> + <% end %> <% end %> <% end %> diff --git a/app/components/DS/disclosure.rb b/app/components/DS/disclosure.rb index bff18f332..ea1c9ec08 100644 --- a/app/components/DS/disclosure.rb +++ b/app/components/DS/disclosure.rb @@ -1,9 +1,9 @@ class DS::Disclosure < DesignSystemComponent renders_one :summary_content - VARIANTS = %i[default card card_inset inline].freeze + VARIANTS = %i[default card card_inset inline bare].freeze - attr_reader :title, :align, :open, :variant, :summary_class_override, :body_class, :opts + attr_reader :title, :align, :open, :variant, :summary_class_override, :summary_aria_label, :body_class, :opts # `:default` — bg-surface summary, no chrome on the `
`. Use # for inline expanders that sit inside a parent card (the summary @@ -25,20 +25,25 @@ class DS::Disclosure < DesignSystemComponent # the summary text (and optional chevron) via the `summary_content` # slot. # + # `:bare` — like `:default` on the `
` element (`group` only), + # but content is yielded without the in-flow body wrapper. Use for + # popovers anchored with absolute/fixed positioning inside the panel + # (e.g. goal/category color-icon pickers). + # # In card / inline variants, callers should pass their own # `summary_content` slot; the built-in title rendering assumes the # `:default` shape. - # `body_class:` styles the wrapper around the disclosure body. Defaults - # to `mt-2` (the standard gap below the summary). Pass `nil`/`""` to drop - # it — e.g. when the body is an absolutely-positioned popover whose - # wrapper would otherwise add ~8px of normal-flow margin and shove - # siblings down on open (see `goals/_color_picker`). - def initialize(title: nil, align: "right", open: false, variant: :default, summary_class: nil, body_class: "mt-2", **opts) + # + # `body_class:` styles the wrapper around the disclosure body (ignored for + # `:bare`, which renders no wrapper). Defaults to `mt-2` (the standard gap + # below the summary). Pass `nil`/`""` to drop it on non-bare variants. + def initialize(title: nil, align: "right", open: false, variant: :default, summary_class: nil, summary_aria_label: nil, body_class: "mt-2", **opts) @title = title @align = align.to_sym @open = open @variant = variant&.to_sym @summary_class_override = summary_class + @summary_aria_label = summary_aria_label @body_class = body_class @opts = opts @@ -71,18 +76,21 @@ class DS::Disclosure < DesignSystemComponent case variant when :card, :card_inset # Card variants: no bg on summary — the parent details *is* the - # surface. Keep cursor + focus-visible ring + flex baseline. - # Ring token matches `settings/provider_card.html.erb` (the - # established focus pattern on container cards). + # surface. Keep cursor + focus ring + flex baseline. "list-none cursor-pointer focus-ring rounded-xl" when :inline # Inline variant: no surface, no padding — the summary reads as # plain text-link copy. Caller markup (text + optional chevron) - # provides the visual. Keep cursor + focus-visible ring + matching - # alpha-black-300 token used by the card variants for consistency. + # provides the visual. "list-none cursor-pointer focus-ring rounded-sm" + when :bare + "list-none cursor-pointer focus-ring" else "px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface focus-ring min-h-11" end end + + def summary_aria_attrs + summary_aria_label.present? ? { label: summary_aria_label } : {} + end end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 161015e76..2f1a1cbaa 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -6,7 +6,10 @@ class CategoriesController < ApplicationController def index @categories = Current.family.categories.alphabetically_by_hierarchy.to_a @category_groups = Category::Group.for(@categories) - @category_ids_with_transactions = category_ids_with_transactions(@categories) + @category_ids_with_transactions = Category.ids_with_transactions( + family: Current.family, + category_ids: @categories.map(&:id) + ) render layout: "settings" end @@ -125,17 +128,6 @@ class CategoriesController < ApplicationController params.permit(:target_id, source_ids: []) end - def category_ids_with_transactions(categories) - category_ids = categories.map(&:id) - return {} if category_ids.empty? - - Current.family.transactions - .where(category_id: category_ids) - .distinct - .pluck(:category_id) - .index_with(true) - end - def record_error_message(error) record = error.respond_to?(:record) ? error.record : nil record&.errors&.full_messages&.to_sentence.presence || error.message diff --git a/app/javascript/controllers/color_icon_picker_controller.js b/app/javascript/controllers/color_icon_picker_controller.js index a208ba969..59e321d63 100644 --- a/app/javascript/controllers/color_icon_picker_controller.js +++ b/app/javascript/controllers/color_icon_picker_controller.js @@ -152,10 +152,10 @@ export default class extends Controller { "Poor contrast, choose darker color or auto-adjust.", ); - this.validationMessageTarget.classList.remove("hidden"); + this.showFlex(this.validationMessageTarget); } else { this.colorInputTarget.setCustomValidity(""); - this.validationMessageTarget.classList.add("hidden"); + this.hideFlex(this.validationMessageTarget); } } @@ -226,7 +226,7 @@ export default class extends Controller { showPaletteSection() { this.initPicker(); this.colorsSectionTarget.classList.add("hidden"); - this.paletteSectionTarget.classList.remove("hidden"); + this.showFlex(this.paletteSectionTarget); this.pickerSectionTarget.classList.remove("hidden"); this.updatePopupPosition(); this.picker.show(); @@ -234,7 +234,7 @@ export default class extends Controller { showColorsSection() { this.colorsSectionTarget.classList.remove("hidden"); - this.paletteSectionTarget.classList.add("hidden"); + this.hideFlex(this.paletteSectionTarget); this.pickerSectionTarget.classList.add("hidden"); this.updatePopupPosition() if (this.picker) { @@ -256,6 +256,16 @@ export default class extends Controller { } }; + showFlex(element) { + element.classList.remove("hidden"); + element.classList.add("flex"); + } + + hideFlex(element) { + element.classList.add("hidden"); + element.classList.remove("flex"); + } + updatePopupPosition() { const popup = this.popupTarget; popup.style.top = ""; diff --git a/app/models/category.rb b/app/models/category.rb index fdc25dd0a..7bad10cd5 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -137,6 +137,17 @@ class Category < ApplicationRecord end class << self + def ids_with_transactions(family:, category_ids:) + category_ids = Array(category_ids).compact + return {} if category_ids.empty? + + family.transactions + .where(category_id: category_ids) + .distinct + .pluck(:category_id) + .index_with(true) + end + def suggested_icon(name) name_down = name.to_s.downcase @@ -284,9 +295,7 @@ class Category < ApplicationRecord end def inherit_color_from_parent - if subcategory? - self.color = parent.color - end + self.color = parent.color if subcategory? && parent end def replace_and_destroy!(replacement) @@ -297,15 +306,22 @@ class Category < ApplicationRecord end def parent? - subcategories.any? + if association(:subcategories).loaded? + subcategories.any? + else + subcategories.exists? + end end def subcategory? - parent.present? + parent_id.present? && parent.present? end def name_with_parent - subcategory? ? "#{parent.name} > #{name}" : name + return name unless subcategory? + + parent_name = parent&.name + parent_name.present? ? "#{parent_name} > #{name}" : name end def display_name @@ -333,7 +349,7 @@ class Category < ApplicationRecord private def category_level_limit - if (subcategory? && parent.subcategory?) || (parent? && subcategory?) + if (subcategory? && parent&.subcategory?) || (parent? && subcategory?) errors.add(:parent, "can't have more than 2 levels of subcategories") end end diff --git a/app/views/categories/_category.html.erb b/app/views/categories/_category.html.erb index 1fbea37dc..e110bfd72 100644 --- a/app/views/categories/_category.html.erb +++ b/app/views/categories/_category.html.erb @@ -1,10 +1,8 @@ -<%# locals: (category:, subcategories: nil, has_transactions: nil) %> -<% subcategories ||= category.subcategories.to_a %> -<% has_transactions = category.transactions.exists? if has_transactions.nil? %> +<%# locals: (category:, subcategories: [], has_transactions: false) %> -
<%= "pb-4" unless subcategories.any? %> bg-container"> +
bg-container">
- <% if category.subcategory? %> + <% if category.parent_id.present? %> <%= icon "corner-down-right", size: "sm", color: "current", class: "ml-2" %> diff --git a/app/views/categories/_category_list_group.html.erb b/app/views/categories/_category_list_group.html.erb index c498cb48a..7ef2152f1 100644 --- a/app/views/categories/_category_list_group.html.erb +++ b/app/views/categories/_category_list_group.html.erb @@ -1,5 +1,12 @@ -<%# locals: (title:, categories:, category_groups: nil, category_ids_with_transactions: nil) %> +<%# locals: (title:, categories:, family: nil, category_groups: nil, category_ids_with_transactions: nil) %> <% category_groups ||= Category::Group.for(categories) %> +<% if category_ids_with_transactions.nil? %> + <% category_ids_with_transactions = if categories.any? && family + Category.ids_with_transactions(family: family, category_ids: categories.map(&:id)) + else + {} + end %> +<% end %>
@@ -14,13 +21,13 @@ <%= render "categories/category", category: group.category, subcategories: group.subcategories, - has_transactions: category_ids_with_transactions&.key?(group.category.id) %> + has_transactions: category_ids_with_transactions.key?(group.category.id) %> <% group.subcategories.each do |subcategory| %> <%= render "categories/category", category: subcategory, subcategories: [], - has_transactions: category_ids_with_transactions&.key?(subcategory.id) %> + has_transactions: category_ids_with_transactions.key?(subcategory.id) %> <% end %> <% unless idx == category_groups.count - 1 %> diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index a46284903..b974d191d 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -7,6 +7,8 @@ <%= render partial: "color_avatar", locals: { category: category } %> <%= render DS::Disclosure.new( + variant: :bare, + summary_aria_label: t(".trigger_label"), summary_class: "cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-7 h-7 border-subdued rounded-full text-secondary", data: { color_icon_picker_target: "details", @@ -32,13 +34,13 @@
-