feat(rules): support multiple tags in the set transaction tags action (#3397)

* feat(rules): support multiple tags in the set transaction tags action

Fixes #3353. Reuses the existing DS::TagSelect multi-select tag picker
(made generic via attribute:/show_label:) instead of a native
<select multiple>, so the UX matches the rest of the app. Multiple
tag ids are stored as a comma-separated string in the existing
value column, keeping single-tag rows backward compatible with no
migration. Also closes a read-modify-write race in
SetTransactionTags#execute via with_lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rules): address automated review findings on multi-tag actions

- Fix data export/import: multi-tag actions were exported/imported as
  one opaque comma string, losing all but a bogus combined tag on
  restore. Each tag id is now resolved/reconstructed independently,
  with a backward-compatible scalar value_ref for single-tag actions.
- Fix N+1 in Rule::Action#value_display (options queried once per tag).
- Add aria-label to DS::TagSelect's trigger button when show_label is
  false, so the control keeps an accessible name.
- Localize the "to" label in rule action rows (rules.actions.to_label).
- Use a monotonic counter instead of Date.now() for nested form
  indices, closing a same-millisecond collision window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rules): batch tag lookups in multi-tag import resolution

Avoids one find_by query per tag name when reconstructing multi-tag
rule actions during import.

* fix(rules): resolve jjmata review findings on multi-tag action

- rules_controller.js: prefix the JS-side nested-form index counter
  with "new_" so it can never collide with the numeric indexes Rails
  assigns to already-persisted conditions/actions on an edit form.
- data_exporter.rb: key the value_ref scalar/array decision off the
  number of tag ids on the action, not the number that still resolve,
  so a partially-orphaned multi-tag action keeps round-tripping as an
  array.
- rule_import.rb: split comma-separated set_transaction_tags values
  into individual tag names during CSV rule import, matching the
  batched resolution already used by Family::DataImporter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rules): CSV-quote multi-tag names so commas don't split them

CodeRabbit flagged that a tag name containing a comma (e.g. "Food,
Dining") would be silently split into two tags when round-tripped
through the comma-separated multi-tag value/CSV formats used by
Family::DataExporter, Family::DataImporter, and RuleImport.

Add Rule::Action.encode_multi_value_names/.decode_multi_value_names,
backed by Ruby's CSV line quoting, and use them at all three call
sites instead of a plain join(",")/split(","). A single name without
a comma round-trips byte-identical to before, so existing exports and
CSV rule templates are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: GFR <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GFR
2026-09-07 08:13:59 +02:00
committed by GitHub
co-authored by Claude Sonnet 5 GFR
parent ced8d0ccb6
commit 457698f75b
17 changed files with 542 additions and 50 deletions
@@ -7,6 +7,7 @@ export default class extends Controller {
"destroyField",
"actionValue",
"selectTemplate",
"multiSelectTemplate",
"textTemplate"
];
@@ -32,6 +33,8 @@ export default class extends Controller {
if (actionExecutor.type === "select") {
this.#buildSelectFor(actionExecutor);
} else if (actionExecutor.type === "multi_select") {
this.#buildMultiSelectFor();
} else if (actionExecutor.type === "text") {
this.#buildTextInputFor();
} else {
@@ -78,6 +81,17 @@ export default class extends Controller {
this.actionValueTarget.classList.remove("hidden");
}
#buildMultiSelectFor() {
// The tag list is baked into the server-rendered template (it doesn't
// depend on the selected executor like other select options do), so we
// just clone it as-is. The embedded tag-select Stimulus controller
// connects automatically once the clone is inserted into the DOM.
const template = this.multiSelectTemplateTarget.content.cloneNode(true);
this.actionValueTarget.appendChild(template);
this.actionValueTarget.classList.remove("hidden");
}
#buildTextInputFor() {
// Clone the text template
const template = this.textTemplateTarget.content.cloneNode(true);
@@ -50,7 +50,12 @@ export default class extends Controller {
}
#uniqueKey() {
return Date.now();
// Prefixed so it can never collide with the numeric indexes Rails
// assigns to already-persisted conditions/actions when rendering an
// edit form (0, 1, 2, ...). A plain monotonic counter starting at 1
// would otherwise reuse index 1 and clobber an existing nested record.
this.keySequence = (this.keySequence ?? 0) + 1;
return `new_${this.keySequence}`;
}
// Updates the prefix visibility of all conditions and condition groups