feat(transactions): add inline tag creation and search in txn form (#1719)

* 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 <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Ang Wei Feng (Ted)
2026-06-02 03:46:32 +08:00
committed by GitHub
parent f4eb58d5d6
commit 5e558fa3ab
29 changed files with 878 additions and 77 deletions

View File

@@ -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;

View File

@@ -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",

View File

@@ -0,0 +1,74 @@
<div class="relative"
data-controller="tag-select"
data-tag-select-create-url-value="<%= helpers.tags_path(format: :json) %>"
data-tag-select-field-name-value="<%= field_name %>"
data-tag-select-default-color-value="<%= Tag::COLORS.first %>"
data-tag-select-disabled-value="<%= disabled %>"
data-tag-select-auto-submit-value="<%= auto_submit %>"
data-tag-select-update-url-value="<%= update_url %>"
data-tag-select-menu-placement-value="<%= menu_placement %>"
data-tag-select-offset-value="<%= offset %>"
data-action="click@window->tag-select#handleOutsideClick keydown->tag-select#handleKeydown">
<div class="form-field">
<div class="form-field__body">
<%= form.label :tag_ids, helpers.t("transactions.form.tags_label"), class: "form-field__label" %>
<button type="button"
class="form-field__input form-field__input--multiselect-trigger min-h-7 flex items-center gap-2 pr-8 <%= "cursor-not-allowed text-subdued" if disabled %>"
data-tag-select-target="button"
data-action="click->tag-select#toggle"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="<%= menu_id %>"
<%= "disabled" if disabled %>>
<span class="flex flex-wrap gap-1"
data-tag-select-target="selectionContainer"
data-placeholder="<%= helpers.t("transactions.form.none") %>">
<span class="text-secondary"><%= helpers.t("transactions.form.none") %></span>
</span>
</button>
</div>
</div>
<div data-tag-select-hidden-inputs>
<input type="hidden" name="<%= field_name %>" value="" <%= "disabled" if disabled %>>
</div>
<% unless disabled %>
<div class="absolute z-50 p-1.5 w-full min-w-32 rounded-lg shadow-lg shadow-border-xs bg-container mt-1.5 transition duration-150 ease-out -translate-y-1 opacity-0 hidden"
data-tag-select-target="menu">
<div class="relative flex items-center bg-container border border-secondary rounded-lg mb-1">
<input type="search"
placeholder="<%= helpers.t("transactions.form.tag_search_placeholder") %>"
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") %>
</div>
<div class="flex flex-col gap-1 max-h-64 overflow-auto"
id="<%= menu_id %>"
role="listbox"
aria-multiselectable="true"
tabindex="-1">
<% tags.each do |tag| %>
<% selected = selected_ids.include?(tag.id.to_s) %>
<%= render partial: "DS/tag_select/option", locals: { tag: tag, selected: selected, view_helpers: helpers } %>
<% end %>
<button type="button"
class="hidden text-primary text-sm cursor-pointer items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-container-inset-hover"
data-tag-select-target="createForm"
data-action="click->tag-select#createTag">
<%= helpers.icon("plus") %>
<span><%= helpers.t("transactions.form.create_tag") %> "<span data-tag-select-create-name></span>"</span>
</button>
<p class="hidden px-3 py-1 text-xs text-destructive"
data-tag-select-target="createError"
aria-live="assertive"
aria-atomic="true"></p>
</div>
</div>
<% end %>
</div>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -0,0 +1,23 @@
<button type="button"
class="filterable-item text-primary text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if selected %>"
role="option"
aria-selected="<%= selected %>"
tabindex="-1"
data-action="click->tag-select#toggleTag"
data-tag-select-target="option"
data-tag-id="<%= tag.id %>"
data-tag-name="<%= tag.name %>"
data-tag-color="<%= tag.color %>"
data-filter-name="<%= tag.name %>">
<span class="check-icon w-5 shrink-0 <%= "hidden" unless selected %>">
<%= view_helpers.icon("check") %>
</span>
<span data-tag-select-badge>
<%= render DS::Pill.new(
label: tag.name,
marker: false,
size: :md,
custom_color: tag.color
) %>
</span>
</button>

View File

@@ -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 @@
</section>
<%= 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" %>
<section class="space-y-2">
<%= 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" %>
</section>
<% end %>
<section>

View File

@@ -96,40 +96,41 @@
</div>
<% 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" } %>
<div class="space-y-2">
<%= 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? %>
</div>
<% end %>
<% dialog.with_section(title: t(".attachments")) do %>

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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
toggle_selection_checkboxes: Alternar todas las casillas

View File

@@ -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:

View File

@@ -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:

View File

@@ -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
toggle_selection_checkboxes: Veksle alle avkryssingsbokser

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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
toggle_selection_checkboxes: Tüm onay kutularını değiştir

View File

@@ -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:

View File

@@ -15,7 +15,9 @@ zh-TW:
none: (無)
note_label: 備註
note_placeholder: 輸入備註
create_tag: 建立
submit: 新增交易
tag_search_placeholder: 搜尋或建立標籤
tags_label: 標籤
transfer: 轉帳
new:

View File

@@ -382,6 +382,7 @@ Rails.application.routes.draw do
post :merge_duplicate
post :dismiss_duplicate
post :unlock
patch :tags, action: :update_tags
end
end

View File

@@ -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"))

View File

@@ -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

View File

@@ -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")