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 @@
+
+
+
+
+ >
+
+
+ <% unless disabled %>
+
+
+ "
+ autocomplete="off"
+ class="bg-container text-primary text-sm placeholder:text-secondary font-normal h-10 pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
+ data-tag-select-target="search"
+ data-action="input->tag-select#filter keydown->tag-select#handleSearchKeydown">
+ <%= helpers.icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
+
+
+
+
+ <% 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")