mirror of
https://github.com/we-promise/sure.git
synced 2026-06-03 17:59:05 +00:00
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:
committed by
GitHub
parent
f4eb58d5d6
commit
5e558fa3ab
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
74
app/components/DS/tag_select.html.erb
Normal file
74
app/components/DS/tag_select.html.erb
Normal 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>
|
||||
33
app/components/DS/tag_select.rb
Normal file
33
app/components/DS/tag_select.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
485
app/javascript/controllers/tag_select_controller.js
Normal file
485
app/javascript/controllers/tag_select_controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
23
app/views/DS/tag_select/_option.html.erb
Normal file
23
app/views/DS/tag_select/_option.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -15,7 +15,9 @@ zh-TW:
|
||||
none: (無)
|
||||
note_label: 備註
|
||||
note_placeholder: 輸入備註
|
||||
create_tag: 建立
|
||||
submit: 新增交易
|
||||
tag_search_placeholder: 搜尋或建立標籤
|
||||
tags_label: 標籤
|
||||
transfer: 轉帳
|
||||
new:
|
||||
|
||||
@@ -382,6 +382,7 @@ Rails.application.routes.draw do
|
||||
post :merge_duplicate
|
||||
post :dismiss_duplicate
|
||||
post :unlock
|
||||
patch :tags, action: :update_tags
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user