From 749944fc25724c6bd2af43795783fffdf74f518c Mon Sep 17 00:00:00 2001 From: Gaetan Craig-Riou Date: Mon, 6 Oct 2025 16:12:33 +1100 Subject: [PATCH] Rework TagListInputComponent to integrate autocomplete The component now will try to load a list of existing tag if you give an `autocomplete_url`. I tried to keep the tag input and the autocomplete functionality decoupled but is wasn't really possible. Instead I opted to sub class the Autocomplete stimulus controller, but it only gets initialised if we pass an `autocomplete_url`. --- app/components/tag_list_input_component.rb | 6 +- .../tag_list_input_component.html.haml | 11 +-- .../tag_list_input_controller.js | 91 ++++++++++++++++--- .../variant_tag_list_input_component.rb | 24 ----- ...variant_tag_list_input_component.html.haml | 9 -- .../variant_tag_list_input_controller.js | 31 ------- .../admin/products_v3/_variant_row.html.haml | 2 +- 7 files changed, 88 insertions(+), 86 deletions(-) delete mode 100644 app/components/variant_tag_list_input_component.rb delete mode 100644 app/components/variant_tag_list_input_component/variant_tag_list_input_component.html.haml delete mode 100644 app/components/variant_tag_list_input_component/variant_tag_list_input_controller.js diff --git a/app/components/tag_list_input_component.rb b/app/components/tag_list_input_component.rb index 59c657304f..2e9265f5bd 100644 --- a/app/components/tag_list_input_component.rb +++ b/app/components/tag_list_input_component.rb @@ -6,18 +6,18 @@ class TagListInputComponent < ViewComponent::Base only_one: false, aria_label: nil, hidden_field_data_options: {}, - input_field_data_options: {}) + autocomplete_url: "") @name = name @tags = tags @placeholder = placeholder @only_one = only_one @aria_label_option = aria_label ? { 'aria-label': aria_label } : {} @hidden_field_data_options = hidden_field_data_options - @input_field_data_options = input_field_data_options + @autocomplete_url = autocomplete_url end attr_reader :name, :tags, :placeholder, :only_one, :aria_label_option, - :hidden_field_data_options, :input_field_data_options + :hidden_field_data_options, :autocomplete_url private diff --git a/app/components/tag_list_input_component/tag_list_input_component.html.haml b/app/components/tag_list_input_component/tag_list_input_component.html.haml index 20b60e5d3d..a2d72813ba 100644 --- a/app/components/tag_list_input_component/tag_list_input_component.html.haml +++ b/app/components/tag_list_input_component/tag_list_input_component.html.haml @@ -1,4 +1,4 @@ -%div{ "data-controller": "tag-list-input", "data-tag-list-input-only-one-value": "#{only_one}" } +%div{ "data-controller": "tag-list-input", "data-tag-list-input-only-one-value": "#{only_one}", "data-tag-list-input-url-value": autocomplete_url, "data-action": "autocomplete.change->tag-list-input#addTag" } .tags-input .tags - # We use display:none instead of hidden field, so changes to the value can be picked up by the bulkFormController @@ -20,9 +20,8 @@ nil, { class: "input", placeholder: placeholder, - "data-action": "keydown.enter->tag-list-input#addTag keyup->tag-list-input#filterInput blur->tag-list-input#addTag", - "data-tag-list-input-target": "newTag", + "data-action": "keydown.enter->tag-list-input#keyboardAddTag keyup->tag-list-input#filterInput blur->tag-list-input#onBlur focus->tag-list-input#onInputChange", + "data-tag-list-input-target": "input", **aria_label_option, - style: "display: #{display};"}.merge(input_field_data_options)) - - if content? - = content + style: "display: #{display};"}) + %ul.suggestion-list{ "data-tag-list-input-target": "results" } diff --git a/app/components/tag_list_input_component/tag_list_input_controller.js b/app/components/tag_list_input_component/tag_list_input_controller.js index 094e3cca4a..5c91413a34 100644 --- a/app/components/tag_list_input_component/tag_list_input_controller.js +++ b/app/components/tag_list_input_component/tag_list_input_controller.js @@ -1,14 +1,23 @@ -import { Controller } from "stimulus"; +import { Autocomplete } from "stimulus-autocomplete"; -export default class extends Controller { - static targets = ["tagList", "newTag", "template", "list"]; +// Extend the stimulus-autocomplete controller, so we can load tag with existing rules +// The autocomplete functionality is only loaded if the url value is set +// For more informatioon on "stimulus-autocomplete", see: +// https://github.com/afcapel/stimulus-autocomplete/tree/main +// +export default class extends Autocomplete { + static targets = ["tagList", "input", "template", "list"]; static values = { onlyOne: Boolean }; - addTag(event) { - // prevent hotkey form submitting the form (default action for "enter" key) - event.preventDefault(); + connect() { + // Don't start autocomplete controller if we don't have an url + if (this.urlValue.length == 0) return; - const newTagName = this.newTagTarget.value.trim().replaceAll(" ", "-"); + super.connect(); + } + + addTag(event) { + const newTagName = this.inputTarget.value.trim().replaceAll(" ", "-"); if (newTagName.length == 0) { return; } @@ -18,7 +27,7 @@ export default class extends Controller { const index = tags.indexOf(newTagName); if (index != -1) { // highlight the value in red - this.newTagTarget.classList.add("tag-error"); + this.inputTarget.classList.add("tag-error"); return; } @@ -38,14 +47,23 @@ export default class extends Controller { this.listTarget.appendChild(newTagElement); // Clear new tag value - this.newTagTarget.value = ""; + this.inputTarget.value = ""; // hide tag input if limited to one tag if (this.tagListTarget.value.split(",").length == 1 && this.onlyOneValue == true) { - this.newTagTarget.style.display = "none"; + this.inputTarget.style.display = "none"; } } + keyboardAddTag(event) { + // prevent hotkey form submitting the form (default action for "enter" key) + if (event) { + event.preventDefault(); + } + + this.addTag(); + } + removeTag(event) { // Text to remove const tagName = event.srcElement.previousElementSibling.textContent; @@ -62,14 +80,14 @@ export default class extends Controller { // Make sure the tag input is displayed if (this.tagListTarget.value.length == 0) { - this.newTagTarget.style.display = "block"; + this.inputTarget.style.display = "block"; } } filterInput(event) { // clear error class if key is not enter if (event.key !== "Enter") { - this.newTagTarget.classList.remove("tag-error"); + this.inputTarget.classList.remove("tag-error"); } // Strip comma from tag name @@ -77,4 +95,53 @@ export default class extends Controller { event.srcElement.value = event.srcElement.value.replace(",", ""); } } + + // Add tag if we don't have an autocomplete list open + onBlur() { + // check if we have any autocomplete results + if (this.resultsTarget.childElementCount == 0) this.addTag(); + } + + // Override original to add tag filtering + replaceResults(html) { + const filteredHtml = this.#filterResults(html); + + // Don't show result if we don't have anything to show + if (filteredHtml.length == 0) return; + + super.replaceResults(filteredHtml); + } + + // Override original to all empty query, which will return all existing tags + onInputChange = () => { + if (this.urlValue.length == 0) return; + + if (this.hasHiddenTarget) this.hiddenTarget.value = ""; + + const query = this.inputTarget.value.trim(); + if (query.length >= this.minLengthValue) { + this.fetchResults(query); + } else { + this.hideAndRemoveOptions(); + } + }; + + //private + + #filterResults(html) { + const existingTags = this.tagListTarget.value.split(","); + // Parse the HTML + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const lis = doc.getElementsByTagName("li"); + // Filter + let filteredHtml = ""; + for (let li of lis) { + if (!existingTags.includes(li.dataset.autocompleteValue)) { + filteredHtml += li.outerHTML; + } + } + + return filteredHtml; + } } diff --git a/app/components/variant_tag_list_input_component.rb b/app/components/variant_tag_list_input_component.rb deleted file mode 100644 index 7297f0b2e4..0000000000 --- a/app/components/variant_tag_list_input_component.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class VariantTagListInputComponent < ViewComponent::Base - def initialize(name:, variant:, tag_list_input_component: TagListInputComponent, - placeholder: I18n.t("components.tag_list_input.default_placeholder"), - aria_label: nil) - @tag_list_input_component = tag_list_input_component - @variant = variant - @name = name - @tags = variant.tag_list - @placeholder = placeholder - @only_one = false - @aria_label = aria_label - end - - attr_reader :tag_list_input_component, :variant, :name, :tags, :placeholder, :only_one, - :aria_label - - private - - def autocomplete_url - "/admin/tag_rules/variant_tag_rules?enterprise_id=#{variant.supplier_id}" - end -end diff --git a/app/components/variant_tag_list_input_component/variant_tag_list_input_component.html.haml b/app/components/variant_tag_list_input_component/variant_tag_list_input_component.html.haml deleted file mode 100644 index a6ec8065f7..0000000000 --- a/app/components/variant_tag_list_input_component/variant_tag_list_input_component.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -%div{ "data-controller": "variant-tag-list-input", "data-variant-tag-list-input-url-value": autocomplete_url } - = render tag_list_input_component.new(name:, - tags:, - placeholder:, - aria_label:, - only_one:, - input_field_data_options: { "data-variant-tag-list-input-target": "input" }, - hidden_field_data_options: { "data-variant-tag-list-input-target": "tags" }) do - %ul.suggestion-list{ "data-variant-tag-list-input-target": "results" } diff --git a/app/components/variant_tag_list_input_component/variant_tag_list_input_controller.js b/app/components/variant_tag_list_input_component/variant_tag_list_input_controller.js deleted file mode 100644 index ff187855bd..0000000000 --- a/app/components/variant_tag_list_input_component/variant_tag_list_input_controller.js +++ /dev/null @@ -1,31 +0,0 @@ -import { Autocomplete } from "stimulus-autocomplete"; - -// Extend the stimulus-autocomplete controller, so we can add the ability to filter out existing -// tag -export default class extends Autocomplete { - static targets = ["tags"]; - - replaceResults(html) { - const filteredHtml = this.#filterResults(html); - super.replaceResults(filteredHtml); - } - - //private - - #filterResults(html) { - const existingTags = this.tagsTarget.value.split(","); - // Parse the HTML - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - const lis = doc.getElementsByTagName("li"); - // Filter - let filteredHtml = ""; - for (let li of lis) { - if (!existingTags.includes(li.dataset.autocompleteValue)) { - filteredHtml += li.outerHTML; - } - } - - return filteredHtml; - } -} diff --git a/app/views/admin/products_v3/_variant_row.html.haml b/app/views/admin/products_v3/_variant_row.html.haml index 089061b72e..783eaef37e 100644 --- a/app/views/admin/products_v3/_variant_row.html.haml +++ b/app/views/admin/products_v3/_variant_row.html.haml @@ -80,7 +80,7 @@ = error_message_on variant, :tax_category - if feature?(:variant_tag, spree_current_user) %td.col-tags.field.naked_inputs - = render VariantTagListInputComponent.new(name: f.field_name(:tag_list), variant:, placeholder: t('.add_a_tag'), aria_label: t('admin.products_page.columns.tags')) + = render TagListInputComponent.new(name: f.field_name(:tag_list), tags: variant.tag_list, autocomplete_url: "/admin/tag_rules/variant_tag_rules?enterprise_id=#{variant.supplier_id}", placeholder: t('.add_a_tag'), aria_label: t('admin.products_page.columns.tags')) %td.col-inherits_properties.align-left -# empty %td.align-right