From 5e558fa3abe761c06e39a41f6f8562e227fd652e Mon Sep 17 00:00:00 2001 From: "Ang Wei Feng (Ted)" Date: Tue, 2 Jun 2026 03:46:32 +0800 Subject: [PATCH] feat(transactions): add inline tag creation and search in txn form (#1719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(transactions): add inline tag creation and search in transaction forms * fix(transactions): add tag-only update endpoint for edit drawer * feat(transactions): implement TagSelectComponent for improved tag selection and management * feat(tag-select): refactor tag selection component for improved functionality and accessibility * feat(tag-select): implement inline tag rendering and error handling in tag selection component * refactor(tag-select): remove unused list target from tag select controller * fix: return forbidden JSON for denied tag updates * fix: lock transaction tags when clearing them * refactor: move tag select into DS namespace * refactor: add multiselect trigger form field style * fix: auto-position tag select dropdowns * feat: add keyboard navigation to tag select * feat: add create tag and search placeholder to transaction forms in multiple languages * style: tighten tag select option spacing * fix: align tag select spacing and focus behavior * refactor: render tag badges with DS pill --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- .../sure-design-system/components.css | 5 + app/components/DS/pill.rb | 32 +- app/components/DS/tag_select.html.erb | 74 +++ app/components/DS/tag_select.rb | 33 ++ .../concerns/account_authorizable.rb | 1 + app/controllers/tags_controller.rb | 20 +- app/controllers/transactions_controller.rb | 23 + .../controllers/tag_select_controller.js | 485 ++++++++++++++++++ app/views/DS/tag_select/_option.html.erb | 23 + app/views/transactions/_form.html.erb | 64 +-- app/views/transactions/show.html.erb | 65 +-- config/locales/views/transactions/ca.yml | 2 + config/locales/views/transactions/de.yml | 2 + config/locales/views/transactions/en.yml | 2 + config/locales/views/transactions/es.yml | 4 +- config/locales/views/transactions/fr.yml | 2 + config/locales/views/transactions/hu.yml | 2 + config/locales/views/transactions/nb.yml | 14 +- config/locales/views/transactions/nl.yml | 2 + config/locales/views/transactions/pl.yml | 2 + config/locales/views/transactions/pt-BR.yml | 2 + config/locales/views/transactions/ro.yml | 2 + config/locales/views/transactions/tr.yml | 4 +- config/locales/views/transactions/zh-CN.yml | 4 +- config/locales/views/transactions/zh-TW.yml | 2 + config/routes.rb | 1 + test/components/DS/pill_test.rb | 8 + test/controllers/tags_controller_test.rb | 25 + .../transactions_controller_test.rb | 50 ++ 29 files changed, 878 insertions(+), 77 deletions(-) create mode 100644 app/components/DS/tag_select.html.erb create mode 100644 app/components/DS/tag_select.rb create mode 100644 app/javascript/controllers/tag_select_controller.js create mode 100644 app/views/DS/tag_select/_option.html.erb diff --git a/app/assets/tailwind/sure-design-system/components.css b/app/assets/tailwind/sure-design-system/components.css index 8a4c08b98..7d814388f 100644 --- a/app/assets/tailwind/sure-design-system/components.css +++ b/app/assets/tailwind/sure-design-system/components.css @@ -67,6 +67,11 @@ text-overflow: clip; } + .form-field__input--multiselect-trigger { + @apply whitespace-normal overflow-visible; + text-overflow: clip; + } + select.form-field__input, button.form-field__input { @apply pr-10 appearance-none; diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb index 899c97345..e340466ee 100644 --- a/app/components/DS/pill.rb +++ b/app/components/DS/pill.rb @@ -16,7 +16,7 @@ class DS::Pill < DesignSystemComponent neutral: :gray }.freeze - attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon, :marker + attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon, :marker, :custom_color # Generic inline pill primitive. Two modes: # @@ -44,7 +44,7 @@ class DS::Pill < DesignSystemComponent # - Sure has full violet / indigo / fuchsia / amber / green / gray / # red ramps in the design system; this component picks named tokens # at render time. No raw hex. - def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: true, dot_only: false, title: nil, icon: nil, marker: true) + def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: true, dot_only: false, title: nil, icon: nil, marker: true, custom_color: nil) resolved_tone = SEMANTIC_TONE_ALIASES.fetch(tone.to_sym, tone.to_sym) @label = label || I18n.t("ds.pill.default_label", default: "Beta") @tone = TONES.include?(resolved_tone) ? resolved_tone : :violet @@ -55,6 +55,7 @@ class DS::Pill < DesignSystemComponent @title = title @icon = icon @marker = marker + @custom_color = custom_color end def palette @@ -74,6 +75,8 @@ class DS::Pill < DesignSystemComponent end def container_styles + return custom_color_styles if custom_color.present? + p = palette case style when :filled @@ -98,9 +101,34 @@ class DS::Pill < DesignSystemComponent end def dot_color + return custom_color if custom_color.present? + style == :filled ? "rgba(255,255,255,0.85)" : palette[:dot] end + def custom_color_styles + case style + when :filled + <<~CSS.strip.gsub(/\s+/, " ") + background-color: #{custom_color}; + color: var(--color-white); + border-color: transparent; + CSS + when :outline + <<~CSS.strip.gsub(/\s+/, " ") + background-color: transparent; + color: #{custom_color}; + border-color: color-mix(in oklab, #{custom_color} 40%, transparent); + CSS + else + <<~CSS.strip.gsub(/\s+/, " ") + background-color: color-mix(in oklab, #{custom_color} 10%, transparent); + color: #{custom_color}; + border-color: color-mix(in oklab, #{custom_color} 20%, transparent); + CSS + end + end + def container_classes base = [ "inline-flex items-center align-middle font-medium whitespace-nowrap shrink-0", diff --git a/app/components/DS/tag_select.html.erb b/app/components/DS/tag_select.html.erb new file mode 100644 index 000000000..de5fd5505 --- /dev/null +++ b/app/components/DS/tag_select.html.erb @@ -0,0 +1,74 @@ +
+
+
+ <%= form.label :tag_ids, helpers.t("transactions.form.tags_label"), class: "form-field__label" %> + + +
+
+ +
+ > +
+ + <% unless disabled %> + + <% end %> +
diff --git a/app/components/DS/tag_select.rb b/app/components/DS/tag_select.rb new file mode 100644 index 000000000..95f8cb491 --- /dev/null +++ b/app/components/DS/tag_select.rb @@ -0,0 +1,33 @@ +class DS::TagSelect < DesignSystemComponent + attr_reader :form, :tags, :selected_ids, :disabled, :auto_submit, :update_url, + :menu_placement, :offset + + MENU_PLACEMENTS = %w[auto down up].freeze + + def initialize(form:, tags:, selected_ids:, disabled: false, auto_submit: false, + update_url: nil, menu_placement: :auto, offset: 6) + @form = form + @tags = tags + @selected_ids = selected_ids.map(&:to_s) + @disabled = disabled + @auto_submit = auto_submit + @update_url = update_url + @menu_placement = normalize_menu_placement(menu_placement) + @offset = offset + end + + def field_name + "#{form.object_name}[tag_ids][]" + end + + def menu_id + @menu_id ||= "tag_select_#{field_name.gsub(/\W+/, "_")}_#{object_id}" + end + + private + + def normalize_menu_placement(value) + normalized = value.to_s.downcase + MENU_PLACEMENTS.include?(normalized) ? normalized : "auto" + end +end diff --git a/app/controllers/concerns/account_authorizable.rb b/app/controllers/concerns/account_authorizable.rb index 70bbdd9a4..b2b75043f 100644 --- a/app/controllers/concerns/account_authorizable.rb +++ b/app/controllers/concerns/account_authorizable.rb @@ -23,6 +23,7 @@ module AccountAuthorizable respond_to do |format| format.html { redirect_back_or_to path, alert: t("accounts.not_authorized") } format.turbo_stream { stream_redirect_back_or_to(path, alert: t("accounts.not_authorized")) } + format.json { render json: { error: t("accounts.not_authorized") }, status: :forbidden } end false end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 219971529..315b71093 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -15,9 +15,15 @@ class TagsController < ApplicationController @tag = Current.family.tags.new(tag_params) if @tag.save - redirect_to tags_path, notice: t(".created") + respond_to do |format| + format.html { redirect_to tags_path, notice: t(".created") } + format.json { render json: tag_json(@tag), status: :created } + end else - redirect_to tags_path, alert: t(".error", error: @tag.errors.full_messages.to_sentence) + respond_to do |format| + format.html { redirect_to tags_path, alert: t(".error", error: @tag.errors.full_messages.to_sentence) } + format.json { render json: { errors: @tag.errors.full_messages }, status: :unprocessable_entity } + end end end @@ -48,4 +54,14 @@ class TagsController < ApplicationController def tag_params params.require(:tag).permit(:name, :color) end + + def tag_json(tag) + tag.as_json(only: %i[id name color]).merge( + html: render_to_string( + partial: "DS/tag_select/option", + formats: [ :html ], + locals: { tag: tag, selected: true, view_helpers: helpers } + ) + ) + end end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index fbcd89c18..2fd63e4be 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -2,6 +2,7 @@ class TransactionsController < ApplicationController include EntryableResource before_action :set_entry_for_unlock, only: :unlock + before_action :set_entry_for_tags, only: :update_tags before_action :store_params!, only: :index def new @@ -176,6 +177,20 @@ class TransactionsController < ApplicationController end end + def update_tags + return unless require_account_permission!(@entry.account, :annotate, redirect_path: transaction_path(@entry)) + + tag_ids = Current.family.tags.where(id: tag_ids_param).pluck(:id) + + @entry.transaction.tag_ids = tag_ids + @entry.lock_saved_attributes! + @entry.mark_user_modified! + @entry.transaction.lock_attr!(:tag_ids) + @entry.sync_account_later + + render json: { tag_ids: @entry.transaction.tag_ids } + end + def merge_duplicate transaction = accessible_transactions.includes(entry: :account).find(params[:id]) @@ -466,6 +481,14 @@ class TransactionsController < ApplicationController entry_params end + def tag_ids_param + Array(params[:tag_ids]).reject(&:blank?) + end + + def set_entry_for_tags + set_entry + end + # Filters entry_params based on the user's permission on the account. # read_write users can only annotate (category, tags, notes, merchant). # read_only users cannot update anything. diff --git a/app/javascript/controllers/tag_select_controller.js b/app/javascript/controllers/tag_select_controller.js new file mode 100644 index 000000000..e11e76c17 --- /dev/null +++ b/app/javascript/controllers/tag_select_controller.js @@ -0,0 +1,485 @@ +import { autoUpdate } from "@floating-ui/dom"; +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = [ + "button", + "menu", + "search", + "option", + "selectionContainer", + "createForm", + "createError", + ]; + + static values = { + createUrl: String, + fieldName: String, + defaultColor: String, + disabled: Boolean, + autoSubmit: Boolean, + updateUrl: String, + menuPlacement: { type: String, default: "auto" }, + offset: { type: Number, default: 6 }, + }; + + connect() { + this.creating = false; + this.isOpen = false; + this.selectedIds = new Set( + this.optionTargets + .filter((option) => option.getAttribute("aria-selected") === "true") + .map((option) => option.dataset.tagId), + ); + this.renderSelection(); + this.observeMenuResize(); + } + + disconnect() { + if (this.submitAbortController) this.submitAbortController.abort(); + this.stopAutoUpdate(); + if (this.resizeObserver) this.resizeObserver.disconnect(); + } + + toggle(event) { + event.preventDefault(); + if (this.disabledValue) return; + + this.isOpen ? this.close() : this.open(); + } + + open(focusOption = false) { + this.isOpen = true; + this.buttonTarget.setAttribute("aria-expanded", "true"); + this.menuTarget.classList.remove("hidden"); + this.searchTarget.value = ""; + this.filter(); + this.startAutoUpdate(); + + requestAnimationFrame(() => { + this.menuTarget.classList.remove( + "opacity-0", + "-translate-y-1", + "pointer-events-none", + ); + this.menuTarget.classList.add("opacity-100", "translate-y-0"); + this.updatePosition(); + if (focusOption) { + this.focusActiveOption(); + } + }); + } + + close() { + this.isOpen = false; + this.stopAutoUpdate(); + this.buttonTarget.setAttribute("aria-expanded", "false"); + this.menuTarget.classList.remove("opacity-100", "translate-y-0"); + this.menuTarget.classList.add( + "opacity-0", + "-translate-y-1", + "pointer-events-none", + ); + + setTimeout(() => { + if (!this.isOpen) this.menuTarget.classList.add("hidden"); + }, 150); + } + + toggleTag(event) { + event.preventDefault(); + const option = event.currentTarget; + const id = option.dataset.tagId; + + if (this.selectedIds.has(id)) { + this.selectedIds.delete(id); + } else { + this.selectedIds.add(id); + } + + this.updateOption(option); + this.renderSelection(); + this.submitForm(); + } + + filter() { + this.clearCreateError(); + + const query = this.searchTarget.value.trim().toLowerCase(); + let hasExactMatch = false; + + this.optionTargets.forEach((option) => { + const name = option.dataset.tagName.toLowerCase(); + const isMatch = name.includes(query); + option.classList.toggle("hidden", !isMatch); + + if (name === query) hasExactMatch = true; + }); + + const canCreate = query.length > 0 && !hasExactMatch; + this.createFormTarget.classList.toggle("hidden", !canCreate); + this.createFormTarget.classList.toggle("flex", canCreate); + this.createNameElement.textContent = this.searchTarget.value.trim(); + this.syncActiveOption(); + } + + handleSearchKeydown(event) { + if ( + event.key === "Enter" && + !this.createFormTarget.classList.contains("hidden") && + !this.creating + ) { + event.preventDefault(); + this.createTag(); + } + } + + async createTag() { + if (this.creating) return; + + const name = this.searchTarget.value.trim(); + if (!name) return; + + this.creating = true; + this.createFormTarget.disabled = true; + this.clearCreateError(); + + try { + const response = await fetch(this.createUrlValue, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfToken, + }, + body: JSON.stringify({ + tag: { + name, + color: this.defaultColorValue, + }, + }), + }); + + const tag = await this.parseJson(response); + + if (!response.ok) { + this.showCreateError(tag.errors?.join(", ") || tag.error); + return; + } + + this.createFormTarget.insertAdjacentHTML("beforebegin", tag.html); + this.selectedIds.add(String(tag.id)); + this.renderSelection(); + this.searchTarget.value = ""; + this.filter(); + this.submitForm(); + } finally { + this.creating = false; + this.createFormTarget.disabled = false; + } + } + + renderSelection() { + this.hiddenInputsElement.innerHTML = ""; + this.hiddenInputsElement.appendChild(this.buildHiddenInput("")); + this.selectionContainerTarget.innerHTML = ""; + + const selectedOptions = this.optionTargets.filter((option) => + this.selectedIds.has(option.dataset.tagId), + ); + + selectedOptions.forEach((option) => { + this.hiddenInputsElement.appendChild( + this.buildHiddenInput(option.dataset.tagId), + ); + const badge = option.querySelector("[data-tag-select-badge]"); + if (badge) { + this.selectionContainerTarget.appendChild(badge.cloneNode(true)); + } + this.updateOption(option); + }); + + if (selectedOptions.length === 0) { + this.selectionContainerTarget.appendChild(this.buildPlaceholder()); + } + } + + updateOption(option) { + const isSelected = this.selectedIds.has(option.dataset.tagId); + option.setAttribute("aria-selected", isSelected ? "true" : "false"); + option.classList.toggle("bg-container-inset", isSelected); + + const icon = option.querySelector(".check-icon"); + if (icon) icon.classList.toggle("hidden", !isSelected); + } + + buildHiddenInput(id) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = this.fieldNameValue; + input.value = id; + input.disabled = this.disabledValue; + return input; + } + + handleOutsideClick(event) { + if (this.isOpen && !this.element.contains(event.target)) this.close(); + } + + async submitForm() { + if (!this.autoSubmitValue) return; + if (!this.hasUpdateUrlValue || !this.updateUrlValue) return; + + if (this.submitAbortController) this.submitAbortController.abort(); + + const abortController = new AbortController(); + this.submitAbortController = abortController; + + try { + await fetch(this.updateUrlValue, { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfToken, + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ + tag_ids: Array.from(this.selectedIds), + }), + credentials: "same-origin", + signal: abortController.signal, + }); + } catch (error) { + if (error.name !== "AbortError") throw error; + } finally { + if (this.submitAbortController === abortController) { + this.submitAbortController = null; + } + } + } + + handleKeydown(event) { + if (!this.isOpen && event.target === this.buttonTarget) { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + this.open(true); + } + return; + } + + if (!this.isOpen) return; + + if (event.key === "Escape" && this.isOpen) { + event.preventDefault(); + this.close(); + this.buttonTarget.focus(); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + this.moveActiveOption(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + this.moveActiveOption(-1); + return; + } + + if (event.key === "Home") { + event.preventDefault(); + this.focusOption(this.visibleOptions[0]); + return; + } + + if (event.key === "End") { + event.preventDefault(); + this.focusOption(this.visibleOptions.at(-1)); + return; + } + + if ( + event.key === "Enter" && + event.target.getAttribute("role") === "option" + ) { + event.preventDefault(); + event.target.click(); + } + } + + syncActiveOption() { + const options = this.visibleOptions; + const current = this.activeOption; + const selected = options.find((option) => + this.selectedIds.has(option.dataset.tagId), + ); + + this.setActiveOption( + options.includes(current) ? current : selected || options[0], + false, + ); + } + + moveActiveOption(delta) { + const options = this.visibleOptions; + if (options.length === 0) return; + + const currentIndex = options.indexOf(this.activeOption); + const nextIndex = + currentIndex === -1 + ? delta > 0 + ? 0 + : options.length - 1 + : (currentIndex + delta + options.length) % options.length; + + this.focusOption(options[nextIndex]); + } + + focusActiveOption() { + this.focusOption(this.activeOption || this.visibleOptions[0]); + } + + focusOption(option) { + this.setActiveOption(option, true); + } + + setActiveOption(option, focus) { + this.optionTargets.forEach((target) => { + target.tabIndex = target === option ? 0 : -1; + }); + + if (!option) return; + + if (focus) { + option.focus({ preventScroll: true }); + option.scrollIntoView({ block: "nearest" }); + } + } + + get activeOption() { + return this.optionTargets.find((option) => option.tabIndex === 0); + } + + get visibleOptions() { + return this.optionTargets.filter( + (option) => !option.classList.contains("hidden"), + ); + } + + startAutoUpdate() { + if (!this._cleanup && this.hasButtonTarget && this.hasMenuTarget) { + this._cleanup = autoUpdate(this.buttonTarget, this.menuTarget, () => + this.updatePosition(), + ); + } + } + + stopAutoUpdate() { + if (!this._cleanup) return; + + this._cleanup(); + this._cleanup = null; + } + + observeMenuResize() { + this.resizeObserver = new ResizeObserver(() => { + if (this.isOpen) requestAnimationFrame(() => this.updatePosition()); + }); + this.resizeObserver.observe(this.menuTarget); + } + + getScrollParent(element) { + let parent = element.parentElement; + while (parent) { + const style = getComputedStyle(parent); + const overflowY = style.overflowY; + if (overflowY === "auto" || overflowY === "scroll") return parent; + parent = parent.parentElement; + } + return document.documentElement; + } + + placementMode() { + const mode = (this.menuPlacementValue || "auto").toLowerCase(); + return ["auto", "down", "up"].includes(mode) ? mode : "auto"; + } + + updatePosition() { + if (!this.hasButtonTarget || !this.hasMenuTarget || !this.isOpen) return; + + const container = this.getScrollParent(this.element); + const containerRect = container.getBoundingClientRect(); + const buttonRect = this.buttonTarget.getBoundingClientRect(); + const menuHeight = this.menuTarget.scrollHeight; + + const spaceBelow = containerRect.bottom - buttonRect.bottom; + const spaceAbove = buttonRect.top - containerRect.top; + const placement = this.placementMode(); + const shouldOpenUp = + placement === "up" || + (placement === "auto" && + spaceBelow < menuHeight && + spaceAbove > spaceBelow); + + this.menuTarget.style.left = "0"; + this.menuTarget.style.width = "100%"; + this.menuTarget.style.top = ""; + this.menuTarget.style.bottom = ""; + this.menuTarget.style.overflowY = "auto"; + + if (shouldOpenUp) { + this.menuTarget.style.bottom = "100%"; + this.menuTarget.style.maxHeight = `${Math.max(0, spaceAbove - this.offsetValue)}px`; + } else { + this.menuTarget.style.top = "100%"; + this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px`; + } + } + + get csrfToken() { + return document.querySelector("meta[name='csrf-token']")?.content; + } + + get hiddenInputsElement() { + return this.element.querySelector("[data-tag-select-hidden-inputs]"); + } + + get createNameElement() { + return this.createFormTarget.querySelector("[data-tag-select-create-name]"); + } + + showCreateError(message) { + if (!this.hasCreateErrorTarget) return; + + this.createErrorTarget.textContent = message || "Could not create tag"; + this.createErrorTarget.classList.remove("hidden"); + this.searchTarget.setAttribute("aria-invalid", "true"); + this.searchTarget.focus({ preventScroll: true }); + } + + async parseJson(response) { + try { + return await response.json(); + } catch { + return {}; + } + } + + clearCreateError() { + if (!this.hasCreateErrorTarget) return; + + this.createErrorTarget.textContent = ""; + this.createErrorTarget.classList.add("hidden"); + this.searchTarget.removeAttribute("aria-invalid"); + } + + buildPlaceholder() { + const placeholder = document.createElement("span"); + placeholder.className = "text-secondary"; + placeholder.textContent = this.selectionContainerTarget.dataset.placeholder; + return placeholder; + } +} diff --git a/app/views/DS/tag_select/_option.html.erb b/app/views/DS/tag_select/_option.html.erb new file mode 100644 index 000000000..2108d06e7 --- /dev/null +++ b/app/views/DS/tag_select/_option.html.erb @@ -0,0 +1,23 @@ + diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 722adf5ee..1064da60f 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -22,22 +22,22 @@ <%= f.collection_select :account_id, accessible_accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), selected: Current.user.default_account_for_transactions&.id, variant: :logo }, required: true, class: "form-field__input text-ellipsis", data: { transaction_form_target: "account", action: "change->transaction-form#checkCurrencyDifference" } %> <% end %> - <%= f.money_field :amount, - label: t(".amount"), + <%= f.money_field :amount, + label: t(".amount"), required: true, container_class: "money-field-wrapper", amount_data: { transaction_form_target: "amount", action: "input->transaction-form#onAmountChange" }, currency_data: { transaction_form_target: "currency", action: "change->transaction-form#onCurrencyChange" } %> - + <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category"), variant: :badge, searchable: true } %> <% end %> - - <%= f.date_field :date, - label: t(".date"), - required: true, - min: Entry.min_supported_date, - max: Date.current, + + <%= f.date_field :date, + label: t(".date"), + required: true, + min: Entry.min_supported_date, + max: Date.current, value: f.object.date || Date.current, data: { transaction_form_target: "date", action: "change->transaction-form#checkCurrencyDifference" } %> @@ -61,6 +61,7 @@ nil, id: "transaction_form_destination_amount", class: "form-field__input", + autocomplete: "off", min: "0", step: "0.00000001", placeholder: "92", @@ -83,29 +84,28 @@ <%= render DS::Disclosure.new(title: t(".details")) do %> - <%= f.fields_for :entryable do |ef| %> - <%= ef.collection_select :merchant_id, - Current.family.available_merchants_for(Current.user).alphabetically, - :id, :name, - { include_blank: t(".none"), - label: t(".merchant_label"), - variant: :logo, - searchable: true, - menu_placement: :auto } %> - <%= ef.select :tag_ids, - Current.family.tags.alphabetically.pluck(:name, :id), - { - include_blank: t(".none"), - multiple: true, - label: t(".tags_label") - }, - { "data-controller": "multi-select" } %> - <% end %> - <%= f.text_area :notes, - label: t(".note_label"), - placeholder: t(".note_placeholder"), - rows: 5, - "data-auto-submit-form-target": "auto" %> +
+ <%= f.fields_for :entryable do |ef| %> + <%= ef.collection_select :merchant_id, + Current.family.available_merchants_for(Current.user).alphabetically, + :id, :name, + { include_blank: t(".none"), + label: t(".merchant_label"), + variant: :logo, + searchable: true, + menu_placement: :auto } %> + <%= render DS::TagSelect.new( + form: ef, + tags: Current.family.tags.alphabetically, + selected_ids: ef.object.tag_ids + ) %> + <% end %> + <%= f.text_area :notes, + label: t(".note_label"), + placeholder: t(".note_placeholder"), + rows: 5, + "data-auto-submit-form-target": "auto" %> +
<% end %>
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 342670518..37cff8d6e 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -96,40 +96,41 @@ <% end %> <% dialog.with_section(title: t(".details")) do %> - <%= styled_form_with model: @entry, - url: transaction_path(@entry), - class: "space-y-2", - data: { controller: "auto-submit-form" } do |f| %> - <%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %> - <% unless @entry.transaction.transfer? %> - <%= f.select :account, - options_for_select( - accessible_accounts.alphabetically.pluck(:name, :id), - @entry.account_id - ), - { label: t(".account_label") }, - { disabled: true } %> - <%= f.fields_for :entryable do |ef| %> - <%= ef.collection_select :merchant_id, - Current.family.available_merchants_for(Current.user).alphabetically, - :id, :name, - { include_blank: t(".none"), - label: t(".merchant_label"), - variant: :logo, searchable: true, menu_placement: :auto, disabled: @entry.split_child? || !can_annotate_entry? }, - "data-auto-submit-form-target": "auto" %> - <%= ef.select :tag_ids, - Current.family.tags.alphabetically.pluck(:name, :id), - { - include_blank: t(".none"), - multiple: true, - label: t(".tags_label"), - disabled: !can_annotate_entry? - }, - { "data-controller": "multi-select", "data-auto-submit-form-target": "auto" } %> +
+ <%= styled_form_with model: @entry, + url: transaction_path(@entry), + class: "space-y-2", + data: { controller: "auto-submit-form" } do |f| %> + <%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %> + <% unless @entry.transaction.transfer? %> + <%= f.select :account, + options_for_select( + accessible_accounts.alphabetically.pluck(:name, :id), + @entry.account_id + ), + { label: t(".account_label") }, + { disabled: true } %> + <%= f.fields_for :entryable do |ef| %> + <%= ef.collection_select :merchant_id, + Current.family.available_merchants_for(Current.user).alphabetically, + :id, :name, + { include_blank: t(".none"), + label: t(".merchant_label"), + variant: :logo, searchable: true, menu_placement: :auto, disabled: @entry.split_child? || !can_annotate_entry? }, + "data-auto-submit-form-target": "auto" %> + <%= render DS::TagSelect.new( + form: ef, + tags: Current.family.tags.alphabetically, + selected_ids: ef.object.tag_ids, + disabled: !can_annotate_entry?, + auto_submit: true, + update_url: tags_transaction_path(@entry) + ) %> + <% end %> <% end %> <% end %> - <% end %> - <%= render "transactions/notes", entry: @entry, can_annotate: can_annotate_entry? %> + <%= render "transactions/notes", entry: @entry, can_annotate: can_annotate_entry? %> +
<% end %> <% dialog.with_section(title: t(".attachments")) do %> diff --git a/config/locales/views/transactions/ca.yml b/config/locales/views/transactions/ca.yml index e147de78d..6b50072d0 100644 --- a/config/locales/views/transactions/ca.yml +++ b/config/locales/views/transactions/ca.yml @@ -163,7 +163,9 @@ ca: none: "(cap)" note_label: Notes note_placeholder: Introdueix una nota + create_tag: Crea submit: Afegeix transacció + tag_search_placeholder: Cerca o crea una etiqueta tags_label: Etiquetes transfer: Transferència header: diff --git a/config/locales/views/transactions/de.yml b/config/locales/views/transactions/de.yml index 2b19d6942..4981f8acf 100644 --- a/config/locales/views/transactions/de.yml +++ b/config/locales/views/transactions/de.yml @@ -16,7 +16,9 @@ de: none: (keine) note_label: Notizen note_placeholder: Notiz eingeben + create_tag: Erstellen submit: Transaktion hinzufügen + tag_search_placeholder: Tag suchen oder erstellen tags_label: Tags transfer: Überweisung new: diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 9e45f810f..147a601d0 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -41,7 +41,9 @@ en: none: (none) note_label: Notes note_placeholder: Enter a note + create_tag: Create submit: Add transaction + tag_search_placeholder: Search or create tag tags_label: Tags transfer: Transfer create: diff --git a/config/locales/views/transactions/es.yml b/config/locales/views/transactions/es.yml index b5d294422..8c09ce98b 100644 --- a/config/locales/views/transactions/es.yml +++ b/config/locales/views/transactions/es.yml @@ -16,7 +16,9 @@ es: none: (ninguno) note_label: Notas note_placeholder: Introduce una nota + create_tag: Crear submit: Añadir transacción + tag_search_placeholder: Buscar o crear etiqueta tags_label: Etiquetas transfer: Transferencia new: @@ -195,4 +197,4 @@ es: greater_than: mayor que less_than: menor que form: - toggle_selection_checkboxes: Alternar todas las casillas \ No newline at end of file + toggle_selection_checkboxes: Alternar todas las casillas diff --git a/config/locales/views/transactions/fr.yml b/config/locales/views/transactions/fr.yml index 8e0c69eee..ae93c78ca 100644 --- a/config/locales/views/transactions/fr.yml +++ b/config/locales/views/transactions/fr.yml @@ -20,7 +20,9 @@ fr: none: (aucun) note_label: Notes note_placeholder: Entrez une note + create_tag: Créer submit: Ajouter la transaction + tag_search_placeholder: Rechercher ou créer une étiquette tags_label: Étiquettes transfer: Virement new: diff --git a/config/locales/views/transactions/hu.yml b/config/locales/views/transactions/hu.yml index bd12ab3c2..8eef07b97 100644 --- a/config/locales/views/transactions/hu.yml +++ b/config/locales/views/transactions/hu.yml @@ -41,7 +41,9 @@ hu: none: (egyik sem) note_label: Megjegyzések note_placeholder: Megjegyzés írása + create_tag: Létrehozás submit: Tranzakció hozzáadása + tag_search_placeholder: Címke keresése vagy létrehozása tags_label: Címkék transfer: Átutalás create: diff --git a/config/locales/views/transactions/nb.yml b/config/locales/views/transactions/nb.yml index 479659c30..53f4fa7b6 100644 --- a/config/locales/views/transactions/nb.yml +++ b/config/locales/views/transactions/nb.yml @@ -12,11 +12,13 @@ nb: description_placeholder: Beskriv transaksjonen expense: Utgift income: Inntekt - none: (ingen) - note_label: Notater - note_placeholder: Skriv et notat - submit: Legg til transaksjon - tags_label: Tagger + none: (ingen) + note_label: Notater + note_placeholder: Skriv et notat + create_tag: Opprett + submit: Legg til transaksjon + tag_search_placeholder: Søk etter eller opprett tagg + tags_label: Tagger transfer: Overføring new: new_transaction: Ny transaksjon @@ -84,4 +86,4 @@ nb: greater_than: større enn less_than: mindre enn form: - toggle_selection_checkboxes: Veksle alle avkryssingsbokser \ No newline at end of file + toggle_selection_checkboxes: Veksle alle avkryssingsbokser diff --git a/config/locales/views/transactions/nl.yml b/config/locales/views/transactions/nl.yml index 25415e4fc..6747ced06 100644 --- a/config/locales/views/transactions/nl.yml +++ b/config/locales/views/transactions/nl.yml @@ -16,7 +16,9 @@ nl: none: (geen) note_label: Notities note_placeholder: Voer een notitie in + create_tag: Maken submit: Transactie toevoegen + tag_search_placeholder: Tag zoeken of aanmaken tags_label: Tags transfer: Overboeking new: diff --git a/config/locales/views/transactions/pl.yml b/config/locales/views/transactions/pl.yml index 9ae58f25e..b3a435539 100644 --- a/config/locales/views/transactions/pl.yml +++ b/config/locales/views/transactions/pl.yml @@ -20,7 +20,9 @@ pl: none: "(brak)" note_label: Notatki note_placeholder: Wprowadź notatkę + create_tag: Utwórz submit: Dodaj transakcję + tag_search_placeholder: Wyszukaj lub utwórz tag tags_label: Tagi transfer: Przelew new: diff --git a/config/locales/views/transactions/pt-BR.yml b/config/locales/views/transactions/pt-BR.yml index 5d898a27a..013976678 100644 --- a/config/locales/views/transactions/pt-BR.yml +++ b/config/locales/views/transactions/pt-BR.yml @@ -15,7 +15,9 @@ pt-BR: none: (nenhum) note_label: Notas note_placeholder: Digite uma nota + create_tag: Criar submit: Adicionar transação + tag_search_placeholder: Pesquisar ou criar tag tags_label: Tags transfer: Transferência new: diff --git a/config/locales/views/transactions/ro.yml b/config/locales/views/transactions/ro.yml index dd0d71087..1fd454ec6 100644 --- a/config/locales/views/transactions/ro.yml +++ b/config/locales/views/transactions/ro.yml @@ -15,7 +15,9 @@ ro: none: (niciunul) note_label: Notițe note_placeholder: Introdu o notiță + create_tag: Creează submit: Adaugă tranzacție + tag_search_placeholder: Caută sau creează etichetă tags_label: Etichete transfer: Transfer new: diff --git a/config/locales/views/transactions/tr.yml b/config/locales/views/transactions/tr.yml index bb27ce92c..f950c4f36 100644 --- a/config/locales/views/transactions/tr.yml +++ b/config/locales/views/transactions/tr.yml @@ -15,7 +15,9 @@ tr: none: (yok) note_label: Notlar note_placeholder: Not girin + create_tag: Oluştur submit: İşlem ekle + tag_search_placeholder: Etiket ara veya oluştur tags_label: Etiketler transfer: Transfer new: @@ -83,4 +85,4 @@ tr: greater_than: daha büyük less_than: daha küçük form: - toggle_selection_checkboxes: Tüm onay kutularını değiştir \ No newline at end of file + toggle_selection_checkboxes: Tüm onay kutularını değiştir diff --git a/config/locales/views/transactions/zh-CN.yml b/config/locales/views/transactions/zh-CN.yml index 01138ef0f..ebf13802a 100644 --- a/config/locales/views/transactions/zh-CN.yml +++ b/config/locales/views/transactions/zh-CN.yml @@ -40,8 +40,10 @@ zh-CN: merchant_label: 商户 none: (无) note_label: 备注 - note_placeholder: 输入备注 + note_placeholder: 请输入备注 + create_tag: 创建 submit: 添加交易 + tag_search_placeholder: 搜索或创建标签 tags_label: 标签 transfer: 转账 create: diff --git a/config/locales/views/transactions/zh-TW.yml b/config/locales/views/transactions/zh-TW.yml index 53c080a8e..9109bc387 100644 --- a/config/locales/views/transactions/zh-TW.yml +++ b/config/locales/views/transactions/zh-TW.yml @@ -15,7 +15,9 @@ zh-TW: none: (無) note_label: 備註 note_placeholder: 輸入備註 + create_tag: 建立 submit: 新增交易 + tag_search_placeholder: 搜尋或建立標籤 tags_label: 標籤 transfer: 轉帳 new: diff --git a/config/routes.rb b/config/routes.rb index cc38ccaae..7eeb12395 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -382,6 +382,7 @@ Rails.application.routes.draw do post :merge_duplicate post :dismiss_duplicate post :unlock + patch :tags, action: :update_tags end end diff --git a/test/components/DS/pill_test.rb b/test/components/DS/pill_test.rb index aab1cc9ad..a066e6e94 100644 --- a/test/components/DS/pill_test.rb +++ b/test/components/DS/pill_test.rb @@ -51,6 +51,14 @@ class DS::PillTest < ViewComponent::TestCase assert_includes pill.palette[:bg], "color-red-50" end + test "custom color renders dynamic badge styles" do + render_inline(DS::Pill.new(label: "Groceries", marker: false, custom_color: "#f97316")) + + pill = page.find("span", text: "Groceries") + assert_includes pill[:style], "color-mix(in oklab, #f97316 10%, transparent)" + assert_includes pill[:style], "color: #f97316" + end + test "icon option renders glyph in place of dot" do render_inline(DS::Pill.new(label: "Syncing", tone: :info, marker: false, icon: "loader")) diff --git a/test/controllers/tags_controller_test.rb b/test/controllers/tags_controller_test.rb index 1dd8accd4..9bf86c4e9 100644 --- a/test/controllers/tags_controller_test.rb +++ b/test/controllers/tags_controller_test.rb @@ -33,6 +33,31 @@ class TagsControllerTest < ActionDispatch::IntegrationTest assert_equal "Tag created", flash[:notice] end + test "should create tag as json" do + assert_difference("Tag.count") do + post tags_url(format: :json), params: { tag: { name: "Quick Tag", color: "#e99537" } } + end + + assert_response :created + + response_body = JSON.parse(response.body) + assert_equal "Quick Tag", response_body["name"] + assert_equal "#e99537", response_body["color"] + assert response_body["id"].present? + assert_includes response_body["html"], "data-tag-select-target=\"option\"" + assert_includes response_body["html"], "data-tag-id=\"#{response_body["id"]}\"" + assert_includes response_body["html"], "data-tag-select-badge" + end + + test "should return json validation errors" do + assert_no_difference("Tag.count") do + post tags_url(format: :json), params: { tag: { name: "" } } + end + + assert_response :unprocessable_entity + assert JSON.parse(response.body)["errors"].present? + end + test "should get edit" do get edit_tag_url(tags.first) assert_response :success diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index a6c2e2a05..3fa6149fc 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -123,6 +123,56 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest assert_equal [ tag.id ], child.reload.entryable.tag_ids end + test "can update tags through tag-only endpoint" do + patch tags_transaction_url(@entry, format: :json), params: { + tag_ids: [ tags(:one).id, tags(:two).id ] + } + + assert_response :success + assert_equal [ tags(:one).id, tags(:two).id ].sort, @entry.reload.entryable.tag_ids.sort + assert_equal @entry.entryable.tag_ids.sort, JSON.parse(response.body)["tag_ids"].sort + end + + test "tag-only endpoint ignores tags from another family" do + other_tag = users(:empty).family.tags.create!(name: "Other family") + + patch tags_transaction_url(@entry, format: :json), params: { + tag_ids: [ tags(:one).id, other_tag.id ] + } + + assert_response :success + assert_equal [ tags(:one).id ], @entry.reload.entryable.tag_ids + end + + test "tag-only endpoint locks tags when clearing all tags" do + @entry.entryable.update!(tag_ids: [ tags(:one).id ], locked_attributes: {}) + + patch tags_transaction_url(@entry, format: :json), params: { + tag_ids: [] + }, as: :json + + assert_response :success + assert_empty @entry.reload.entryable.tag_ids + assert @entry.entryable.locked?(:tag_ids) + end + + test "tag-only endpoint returns forbidden json for read-only users" do + sign_in users(:family_member) + read_only_entry = entries(:transfer_in) + original_tag_ids = read_only_entry.entryable.tag_ids + + patch tags_transaction_url(read_only_entry), params: { + tag_ids: [ tags(:one).id ] + }, headers: { + "Accept" => "application/json" + } + + assert_response :forbidden + assert_equal "application/json", response.media_type + assert_equal I18n.t("accounts.not_authorized"), JSON.parse(response.body)["error"] + assert_equal original_tag_ids, read_only_entry.reload.entryable.tag_ids + end + test "split parent rows mark amount as privacy-sensitive" do entry = create_transaction(account: accounts(:depository), amount: 100, name: "Split parent")