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`.
This commit is contained in:
Gaetan Craig-Riou
2025-10-06 16:12:33 +11:00
parent 3cffc5538a
commit 749944fc25
7 changed files with 88 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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