diff --git a/app/components/example_component/example_component.html.haml b/app/components/example_component/example_component.html.haml index d0ac42c229..c4ec93450a 100644 --- a/app/components/example_component/example_component.html.haml +++ b/app/components/example_component/example_component.html.haml @@ -1 +1,3 @@ -%h1 #{@title} +- # the stimulus contoller "example-component--example" lives in app/component/example_component/example_controller.js +%div{ "data-controller": "example-component--example"} + %h1 #{@title} diff --git a/app/components/example_component/example_controller.js b/app/components/example_component/example_controller.js new file mode 100644 index 0000000000..72957edffd --- /dev/null +++ b/app/components/example_component/example_controller.js @@ -0,0 +1,4 @@ +// This controller will be called "example-component--example", ie "component-subdirectory--js-file-name" +import { Controller } from "stimulus"; + +export default class extends Controller {} diff --git a/app/components/tag_list_input_component.rb b/app/components/tag_list_input_component.rb new file mode 100644 index 0000000000..e83b266a47 --- /dev/null +++ b/app/components/tag_list_input_component.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class TagListInputComponent < ViewComponent::Base + # method in a "hidden_field" form helper and is the method used to get a list of tag on the model + def initialize(form:, method:, tags:, + placeholder: I18n.t("components.tag_list_input.default_placeholder")) + @f = form + @method = method + @tags = tags + @placeholder = placeholder + end + + attr_reader :f, :method, :tags, :placeholder +end 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 new file mode 100644 index 0000000000..52b024c25a --- /dev/null +++ b/app/components/tag_list_input_component/tag_list_input_component.html.haml @@ -0,0 +1,19 @@ +%div{ "data-controller": "tag-list-input-component--tag-list-input" } + .tags-input + .tags + - # We use display:none instead of hidden field, so changes to the value can be picked up by the bulkFormController + = f.text_field method.to_sym, value: tags.join(","), "data-tag-list-input-component--tag-list-input-target": "tagList", "style": "display: none" + %ul.tag-list{"data-tag-list-input-component--tag-list-input-target": "list"} + %template{"data-tag-list-input-component--tag-list-input-target": "template"} + %li.tag-item + .tag-template + %span + %a.remove-button{ "data-action": "click->tag-list-input-component--tag-list-input#removeTag" } + ✖ + - tags.each do |tag| + %li.tag-item + .tag-template + %span=tag + %a.remove-button{ "data-action": "click->tag-list-input-component--tag-list-input#removeTag" } + ✖ + = text_field_tag "variant_add_tag_#{f.object.id}".to_sym, nil, class: "input", placeholder: placeholder, "data-action": "keydown.enter->tag-list-input-component--tag-list-input#addTag keyup->tag-list-input-component--tag-list-input#filterInput", "data-tag-list-input-component--tag-list-input-target": "newTag" diff --git a/app/components/tag_list_input_component/tag_list_input_component.scss b/app/components/tag_list_input_component/tag_list_input_component.scss new file mode 100644 index 0000000000..7e3882214c --- /dev/null +++ b/app/components/tag_list_input_component/tag_list_input_component.scss @@ -0,0 +1,68 @@ +// Tags input +.tags-input { + display: block; + + .tags { + -moz-appearance: textfield; + -webkit-appearance: textfield; + overflow: hidden; + word-wrap: break-word; + cursor: text; + background-color: #fff; + height: 100%; + box-shadow: none; + + &:has(.changed) { + border: 1px solid $color-txt-changed-brd; + border-radius: 4px; + } + + .tag-error { + color: $color-error; + } + + .tag-list { + margin: 0; + padding: 0; + list-style-type: none; + } + + li.tag-item { + border-radius: 3px; + margin: 2px 0 2px 3px; + padding: 0 5px; + display: inline-block; + float: left; + font-size: 14px; + line-height: 25px; + border: none; + box-shadow: none; + color: white !important; + background-image: none; + background-color: $teal; + + .remove-button { + margin: 0 0 0 5px; + padding: 0; + border: none; + background: 0 0; + cursor: pointer; + vertical-align: middle; + font: + 700 16px Arial, + sans-serif; + color: white; + } + } + + .input { + border: 0; + outline: 0; + margin: 2px; + padding: 0 0 0 5px; + float: left; + height: 26px; + font-size: 14px; + } + } +} 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 new file mode 100644 index 0000000000..5fb0795e59 --- /dev/null +++ b/app/components/tag_list_input_component/tag_list_input_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "stimulus"; + +export default class extends Controller { + static targets = ["tagList", "newTag", "template", "list"]; + + addTag(event) { + // prevent hotkey form submitting the form (default action for "enter" key) + event.preventDefault(); + + // Check if tag already exist + const newTagName = this.newTagTarget.value.trim(); + if (newTagName.length == 0) { + return; + } + + const tags = this.tagListTarget.value.split(","); + const index = tags.indexOf(newTagName); + if (index != -1) { + // highlight the value in red + this.newTagTarget.classList.add("tag-error"); + return; + } + + // add to tagList + this.tagListTarget.value = this.tagListTarget.value.concat(`,${newTagName}`); + + // Create new li component with value + const newTagElement = this.templateTarget.content.cloneNode(true); + const spanElement = newTagElement.querySelector("span"); + spanElement.innerText = newTagName; + this.listTarget.appendChild(newTagElement); + + // Clear new tag value + this.newTagTarget.value = ""; + } + + removeTag(event) { + // Text to remove + const tagName = event.srcElement.previousElementSibling.textContent; + + // Remove tag from list + const tags = this.tagListTarget.value.split(","); + this.tagListTarget.value = tags.filter(tag => tag != tagName).join(","); + + // manualy dispatch an Input event so the change gets picked up by the bulk form controller + this.tagListTarget.dispatchEvent(new InputEvent("input")); + + // Remove HTML element from the list + event.srcElement.parentElement.parentElement.remove(); + } + + filterInput(event) { + // clear error class if key is not enter + if (event.key !== "Enter") { + this.newTagTarget.classList.remove("tag-error"); + } + + // Strip comma from tag name + if (event.key === ",") { + event.srcElement.value = event.srcElement.value.replace(",", ""); + } + } +} diff --git a/app/models/spree/variant.rb b/app/models/spree/variant.rb index 7cd8e1a10b..3f16cbcc70 100644 --- a/app/models/spree/variant.rb +++ b/app/models/spree/variant.rb @@ -18,6 +18,8 @@ module Spree acts_as_paranoid + acts_as_taggable + searchable_attributes :sku, :display_as, :display_name, :primary_taxon_id, :supplier_id searchable_associations :product, :default_price, :primary_taxon, :supplier searchable_scopes :active, :deleted diff --git a/app/services/permitted_attributes/variant.rb b/app/services/permitted_attributes/variant.rb index e333eded25..d39e2fbde8 100644 --- a/app/services/permitted_attributes/variant.rb +++ b/app/services/permitted_attributes/variant.rb @@ -8,7 +8,7 @@ module PermittedAttributes :shipping_category_id, :price, :unit_value, :unit_description, :variant_unit, :variant_unit_name, :variant_unit_scale, :display_name, :display_as, :tax_category_id, :weight, :height, :width, :depth, :taxon_ids, - :primary_taxon_id, :supplier_id + :primary_taxon_id, :supplier_id, :tag_list ] end end diff --git a/app/views/admin/products_v3/_product_row.html.haml b/app/views/admin/products_v3/_product_row.html.haml index 707a7192ba..980b9980d8 100644 --- a/app/views/admin/products_v3/_product_row.html.haml +++ b/app/views/admin/products_v3/_product_row.html.haml @@ -18,6 +18,9 @@ %td.col-category.align-left -# empty %td.col-tax_category.align-left +- if feature?(:variant_tag, spree_current_user) + %td.col-tags.align-left + -# empty %td.col-inherits_properties.align-left .content= product.inherits_properties ? 'YES' : 'NO' #TODO: consider using https://github.com/RST-J/human_attribute_values, else use I18n.t (also below) %td.align-right diff --git a/app/views/admin/products_v3/_product_variant_row.html.haml b/app/views/admin/products_v3/_product_variant_row.html.haml index 12b6702cba..f5144fe7d7 100644 --- a/app/views/admin/products_v3/_product_variant_row.html.haml +++ b/app/views/admin/products_v3/_product_variant_row.html.haml @@ -19,7 +19,8 @@ %tr{ 'data-nested-form-target': "target" } %tr.condensed %td - %td{ colspan: 11 } + - colspan = feature?(:variant_tag, spree_current_user) ? 12 : 11 + %td{ colspan: "#{colspan}" } %button.secondary.condensed.naked.icon-plus{ 'data-action': "nested-form#add", 'aria-label': t('.new_variant') } =t('.new_variant') diff --git a/app/views/admin/products_v3/_table.html.haml b/app/views/admin/products_v3/_table.html.haml index 5f30291408..5226461c94 100644 --- a/app/views/admin/products_v3/_table.html.haml +++ b/app/views/admin/products_v3/_table.html.haml @@ -26,11 +26,14 @@ %col.col-producer{ style:"min-width: 6em" }= # (grow to fill) %col.col-category{ width:"8%" } %col.col-tax_category{ width:"8%" } + - if feature?(:variant_tag, spree_current_user) + %col.col-tags{ width:"8%" } %col.col-inherits_properties{ width:"5%" } %col{ width:"5%", style:"min-width: 3em"}= # Actions %thead %tr - %td.form-actions-wrapper{ colspan: 12 } + - colspan = feature?(:variant_tag, spree_current_user) ? 13 : 12 + %td.form-actions-wrapper{ colspan: "#{colspan}" } .form-actions-wrapper2 %fieldset.form-actions{ class: ("hidden" unless defined?(@error_counts)), 'data-bulk-form-target': "actions" } .container @@ -60,6 +63,8 @@ %th.align-left.col-producer= t('admin.products_page.columns.producer') %th.align-left.col-category= t('admin.products_page.columns.category') %th.align-left.col-tax_category= t('admin.products_page.columns.tax_category') + - if feature?(:variant_tag, spree_current_user) + %th.align-left.col-tags= t('admin.products_page.columns.tags') %th.align-left.col-inherits_properties= t('admin.products_page.columns.inherits_properties') %th.align-right= t('admin.products_page.columns.actions') - products.each_with_index do |product, product_index| diff --git a/app/views/admin/products_v3/_variant_row.html.haml b/app/views/admin/products_v3/_variant_row.html.haml index d87763e42f..b454aa01c2 100644 --- a/app/views/admin/products_v3/_variant_row.html.haml +++ b/app/views/admin/products_v3/_variant_row.html.haml @@ -74,6 +74,9 @@ aria_label: t('.tax_category_field_name'), placeholder_value: t('.search_for_tax_categories'))) = error_message_on variant, :tax_category +- if feature?(:variant_tag, spree_current_user) + %td.col-tags.field.naked_inputs + = render TagListInputComponent.new(form: f, method: "tag_list", tags: variant.tag_list, placeholder: t('.add_a_tag')) %td.col-inherits_properties.align-left -# empty %td.align-right diff --git a/app/webpacker/controllers/bulk_form_controller.js b/app/webpacker/controllers/bulk_form_controller.js index 476bf00391..6e82d2ce33 100644 --- a/app/webpacker/controllers/bulk_form_controller.js +++ b/app/webpacker/controllers/bulk_form_controller.js @@ -168,6 +168,8 @@ export default class BulkFormController extends Controller { return !areBothBlank && selectedOption !== defaultSelected; } else { + // This doesn't work with hidden field + // Workaround: use a text field with "display:none;" return element.defaultValue !== undefined && element.value != element.defaultValue; } } diff --git a/app/webpacker/controllers/column_preferences_controller.js b/app/webpacker/controllers/column_preferences_controller.js index 65202920f4..1d314fc4be 100644 --- a/app/webpacker/controllers/column_preferences_controller.js +++ b/app/webpacker/controllers/column_preferences_controller.js @@ -29,6 +29,7 @@ export default class ColumnPreferencesController extends Controller { const element = e.target || e; const name = element.dataset.columnName; + // Css defined in app/webpacker/css/admin/products_v3.scss this.table.classList.toggle(`hide-${name}`, !element.checked); // Reset cell colspans diff --git a/app/webpacker/css/admin/products_v3.scss b/app/webpacker/css/admin/products_v3.scss index bb144cd6e8..9944f515af 100644 --- a/app/webpacker/css/admin/products_v3.scss +++ b/app/webpacker/css/admin/products_v3.scss @@ -184,9 +184,8 @@ } // Hide columns - $columns: - "image", "name", "sku", "unit_scale", "unit", "price", "on_hand", "producer", "category", - "tax_category", "inherits_properties"; + $columns: "image", "name", "sku", "unit_scale", "unit", "price", "on_hand", "producer", + "category", "tax_category", "tags", "inherits_properties"; @each $col in $columns { &.hide-#{$col} { .col-#{$col} { diff --git a/app/webpacker/css/admin_v3/all.scss b/app/webpacker/css/admin_v3/all.scss index a339a20457..3e3cc403bd 100644 --- a/app/webpacker/css/admin_v3/all.scss +++ b/app/webpacker/css/admin_v3/all.scss @@ -128,6 +128,7 @@ @import "app/components/modal_component/modal_component"; @import "app/components/vertical_ellipsis_menu/component"; // admin_v3 and only V3 +@import "app/components/tag_list_input_component/tag_list_input_component"; @import "app/webpacker/css/admin/trix.scss"; @import "terms_of_service_banner"; // admin_v3 diff --git a/config/locales/en.yml b/config/locales/en.yml index de4909d95f..b752d6a9fa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -638,6 +638,7 @@ en: inherits_properties: "Inherits Properties?" import_date: "Import Date" actions: Actions + tags: Tags columns_selector: unit: Unit price: Price @@ -1018,6 +1019,7 @@ en: tax_category_field_name: "Tax Category" producer_field_name: "Producer" select_unit_scale: Select unit scale + add_a_tag: Add a tag clone: success: Successfully cloned the product error: Unable to clone the product @@ -5058,6 +5060,8 @@ See the %{link} to find out more about %{sitename}'s features and to start using pagination: next: Next previous: Previous + tag_list_input: + default_placeholder: Add a tag # Gem to prevent bot form submissions diff --git a/lib/open_food_network/column_preference_defaults.rb b/lib/open_food_network/column_preference_defaults.rb index 9da9ef36e9..9eca6be307 100644 --- a/lib/open_food_network/column_preference_defaults.rb +++ b/lib/open_food_network/column_preference_defaults.rb @@ -81,7 +81,7 @@ module OpenFoodNetwork producer_visibility = display_producer_column?(user) I18n.with_options scope: 'admin.products_page.columns' do - { + columns = { image: { name: t(:image), visible: true }, name: { name: t(:name), visible: true }, sku: { name: t(:sku), visible: true }, @@ -92,8 +92,14 @@ module OpenFoodNetwork producer: { name: t(:producer), visible: producer_visibility }, category: { name: t(:category), visible: true }, tax_category: { name: t(:tax_category), visible: true }, - inherits_properties: { name: t(:inherits_properties), visible: true }, } + if OpenFoodNetwork::FeatureToggle.enabled?(:variant_tag, user) + columns[:tags] = { name: t(:tags), visible: true } + end + + columns[:inherits_properties] = { name: t(:inherits_properties), visible: true } + + columns end end diff --git a/lib/open_food_network/feature_toggle.rb b/lib/open_food_network/feature_toggle.rb index 13c07573b2..524a273f0b 100644 --- a/lib/open_food_network/feature_toggle.rb +++ b/lib/open_food_network/feature_toggle.rb @@ -61,6 +61,9 @@ module OpenFoodNetwork "open_in_same_tab" => <<~DESC, Open the admin dashboard in the same tab instead of a new tab. DESC + "variant_tag" => <<~DESC, + Variant Tag are available on the Bulk Edit Products page. + DESC }.merge(conditional_features).freeze; # Features you would like to be enabled to start with. diff --git a/spec/javascripts/stimulus/bulk_form_controller_test.js b/spec/javascripts/stimulus/bulk_form_controller_test.js index 2f1e8ea429..b09d116b0f 100644 --- a/spec/javascripts/stimulus/bulk_form_controller_test.js +++ b/spec/javascripts/stimulus/bulk_form_controller_test.js @@ -15,16 +15,16 @@ describe("BulkFormController", () => { // Mock I18n. TODO: moved to a shared helper beforeAll(() => { const mockedT = jest.fn(); - mockedT.mockImplementation((string, opts) => (string + ', ' + JSON.stringify(opts))); + mockedT.mockImplementation((string, opts) => string + ", " + JSON.stringify(opts)); - global.I18n = { - t: mockedT + global.I18n = { + t: mockedT, }; - }) + }); // (jest still doesn't have aroundEach https://github.com/jestjs/jest/issues/4543 ) afterAll(() => { delete global.I18n; - }) + }); beforeEach(() => { document.body.innerHTML = ` @@ -33,36 +33,38 @@ describe("BulkFormController", () => {
-
- - - - -
-
- -
- + +
+ + + + +
+
+ +
+ +
`; }); describe("marking changed fields", () => { it("input: onInput", () => { - input1a.value = 'updated1a'; + input1a.value = "updated1a"; input1a.dispatchEvent(new Event("input")); // Expect only first field to show changed - expect(input1a.classList).toContain('changed'); - expect(input1b.classList).not.toContain('changed'); - expect(input2.classList).not.toContain('changed'); + expect(input1a.classList).toContain("changed"); + expect(input1b.classList).not.toContain("changed"); + expect(input2.classList).not.toContain("changed"); // Change back to original value - input1a.value = 'initial1a'; + input1a.value = "initial1a"; input1a.dispatchEvent(new Event("input")); - expect(input1a.classList).not.toContain('changed'); + expect(input1a.classList).not.toContain("changed"); }); it("select: onInput", () => { @@ -71,59 +73,61 @@ describe("BulkFormController", () => { select1.options[1].selected = false; select1.dispatchEvent(new Event("input")); // Expect select to show changed - expect(input1a.classList).not.toContain('changed'); - expect(input1b.classList).not.toContain('changed'); - expect(select1.classList).toContain('changed'); + expect(input1a.classList).not.toContain("changed"); + expect(input1b.classList).not.toContain("changed"); + expect(select1.classList).toContain("changed"); // Change back to original value select1.options[0].selected = false; select1.options[1].selected = true; select1.dispatchEvent(new Event("input")); - expect(select1.classList).not.toContain('changed'); + expect(select1.classList).not.toContain("changed"); }); it("multiple fields", () => { - input1a.value = 'updated1a'; + input1a.value = "updated1a"; input1a.dispatchEvent(new Event("input")); - input2.value = 'updated2'; + input2.value = "updated2"; input2.dispatchEvent(new Event("input")); // Expect only first field to show changed - expect(input1a.classList).toContain('changed'); - expect(input1b.classList).not.toContain('changed'); - expect(input2.classList).toContain('changed'); + expect(input1a.classList).toContain("changed"); + expect(input1b.classList).not.toContain("changed"); + expect(input2.classList).toContain("changed"); // Change only one back to original value - input1a.value = 'initial1a'; + input1a.value = "initial1a"; input1a.dispatchEvent(new Event("input")); - expect(input1a.classList).not.toContain('changed'); - expect(input1b.classList).not.toContain('changed'); - expect(input2.classList).toContain('changed'); + expect(input1a.classList).not.toContain("changed"); + expect(input1b.classList).not.toContain("changed"); + expect(input2.classList).toContain("changed"); }); describe("select not include_blank", () => { beforeEach(() => { document.body.innerHTML = `
-
- -
- + +
+ +
+ +
`; }); it("shows as changed", () => { // Expect select to show changed (select-one always has something selected) - expect(select1.classList).toContain('changed'); + expect(select1.classList).toContain("changed"); // Change selection select1.options[0].selected = false; select1.options[1].selected = true; select1.dispatchEvent(new Event("input")); - expect(select1.classList).toContain('changed'); + expect(select1.classList).toContain("changed"); }); }); @@ -131,87 +135,89 @@ describe("BulkFormController", () => { beforeEach(() => { document.body.innerHTML = `
-
- -
- + +
+ +
+ +
`; }); it("does not show as changed", () => { - expect(select1.classList).not.toContain('changed'); + expect(select1.classList).not.toContain("changed"); // Change selection select1.options[0].selected = false; select1.options[1].selected = true; select1.dispatchEvent(new Event("input")); - expect(select1.classList).toContain('changed'); + expect(select1.classList).toContain("changed"); }); }); - }) + }); describe("activating sections, and showing a summary", () => { // This scenario should probably be broken up into smaller units. it("counts changed records ", () => { // Record 1: First field changed - input1a.value = 'updated1a'; + input1a.value = "updated1a"; input1a.dispatchEvent(new Event("input")); // Actions and changed summary are shown, with other sections disabled - expect(actions.classList).not.toContain('hidden'); + expect(actions.classList).not.toContain("hidden"); expect(changed_summary.textContent).toBe('changed_summary, {"count":1}'); - expect(disable1.classList).toContain('disabled-section'); + expect(disable1.classList).toContain("disabled-section"); expect(disable1_element.disabled).toBe(true); - expect(disable2.classList).toContain('disabled-section'); + expect(disable2.classList).toContain("disabled-section"); expect(disable2_element.disabled).toBe(true); // Record 1: Second field changed - input1b.value = 'updated1b'; + input1b.value = "updated1b"; input1b.dispatchEvent(new Event("input")); // Expect to show same summary translation - expect(actions.classList).not.toContain('hidden'); + expect(actions.classList).not.toContain("hidden"); expect(changed_summary.textContent).toBe('changed_summary, {"count":1}'); // Record 2: has been changed - input2.value = 'updated2'; + input2.value = "updated2"; input2.dispatchEvent(new Event("input")); // Expect summary to count both records - expect(actions.classList).not.toContain('hidden'); + expect(actions.classList).not.toContain("hidden"); expect(changed_summary.textContent).toBe('changed_summary, {"count":2}'); // Record 1: Change first field back to original value - input1a.value = 'initial1a'; + input1a.value = "initial1a"; input1a.dispatchEvent(new Event("input")); // Both records are still changed. - expect(input1a.classList).not.toContain('changed'); - expect(input1b.classList).toContain('changed'); - expect(input2.classList).toContain('changed'); - expect(actions.classList).not.toContain('hidden'); + expect(input1a.classList).not.toContain("changed"); + expect(input1b.classList).toContain("changed"); + expect(input2.classList).toContain("changed"); + expect(actions.classList).not.toContain("hidden"); expect(changed_summary.textContent).toBe('changed_summary, {"count":2}'); // Record 1: Change second field back to original value - input1b.value = 'initial1b'; + input1b.value = "initial1b"; input1b.dispatchEvent(new Event("input")); // Both fields for record 1 show unchanged, but second record is still changed - expect(actions.classList).not.toContain('hidden'); + expect(actions.classList).not.toContain("hidden"); expect(changed_summary.textContent).toBe('changed_summary, {"count":1}'); - expect(disable1.classList).toContain('disabled-section'); + expect(disable1.classList).toContain("disabled-section"); expect(disable1_element.disabled).toBe(true); - expect(disable2.classList).toContain('disabled-section'); + expect(disable2.classList).toContain("disabled-section"); expect(disable2_element.disabled).toBe(true); // Record 2: Change back to original value - input2.value = 'initial2'; + input2.value = "initial2"; input2.dispatchEvent(new Event("input")); // Actions are hidden and other sections are now re-enabled - expect(actions.classList).toContain('hidden'); + expect(actions.classList).toContain("hidden"); expect(changed_summary.textContent).toBe('changed_summary, {"count":0}'); - expect(disable1.classList).not.toContain('disabled-section'); + expect(disable1.classList).not.toContain("disabled-section"); expect(disable1_element.disabled).toBe(false); - expect(disable2.classList).not.toContain('disabled-section'); + expect(disable2.classList).not.toContain("disabled-section"); expect(disable2_element.disabled).toBe(false); }); }); @@ -221,13 +227,15 @@ describe("BulkFormController", () => { beforeEach(() => { document.body.innerHTML = `
-
- An error occurred. - -
-
- -
+ +
+ An error occurred. + +
+
+ +
+
`; @@ -238,19 +246,19 @@ describe("BulkFormController", () => { it("form actions section remains visible", () => { // Expect actions to remain visible - expect(actions.classList).not.toContain('hidden'); + expect(actions.classList).not.toContain("hidden"); // Record 1: First field changed - input1a.value = 'updated1a'; + input1a.value = "updated1a"; input1a.dispatchEvent(new Event("input")); // Expect actions to remain visible - expect(actions.classList).not.toContain('hidden'); + expect(actions.classList).not.toContain("hidden"); // Change back to original value - input1a.value = 'initial1a'; + input1a.value = "initial1a"; input1a.dispatchEvent(new Event("input")); // Expect actions to remain visible - expect(actions.classList).not.toContain('hidden'); + expect(actions.classList).not.toContain("hidden"); }); }); @@ -258,16 +266,18 @@ describe("BulkFormController", () => { beforeEach(() => { document.body.innerHTML = `
- - - -
- -
- + +
+ + +
+
+ +
+ +
`; }); @@ -282,18 +292,18 @@ describe("BulkFormController", () => { }); it("onInput", () => { - input1b.value = 'updated1b'; + input1b.value = "updated1b"; input1b.dispatchEvent(new Event("input")); // Expect only updated field to show changed - expect(input1b.classList).toContain('changed'); - expect(input2.classList).not.toContain('changed'); + expect(input1b.classList).toContain("changed"); + expect(input2.classList).not.toContain("changed"); // Change back to original value - input1b.value = 'initial1b'; + input1b.value = "initial1b"; input1b.dispatchEvent(new Event("input")); - expect(input1b.classList).not.toContain('changed'); + expect(input1b.classList).not.toContain("changed"); }); - }) + }); }); // unable to test disconnect at this stage diff --git a/spec/javascripts/stimulus/tag_list_input_controller_test.js b/spec/javascripts/stimulus/tag_list_input_controller_test.js new file mode 100644 index 0000000000..dd8461a360 --- /dev/null +++ b/spec/javascripts/stimulus/tag_list_input_controller_test.js @@ -0,0 +1,166 @@ +/** + * @jest-environment jsdom + */ + +import { Application } from "stimulus"; +import tag_list_input_controller from "../../../app/components/tag_list_input_component/tag_list_input_controller"; + +describe("TagListInputController", () => { + beforeAll(() => { + const application = Application.start(); + application.register("tag-list-input-component--tag-list-input", tag_list_input_controller); + }); + + beforeEach(() => { + document.body.innerHTML = ` +
+ +
+
+
    + +
  • +
    + tag 1 + +
    +
  • +
  • +
    + tag 2 + +
    +
  • +
  • +
    + tag 3 + +
    +
  • +
+ +
+
+
`; + }); + + describe("addTag", () => { + beforeEach(() => { + variant_add_tag.value = "new_tag"; + variant_add_tag.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + }); + + it("updates the hidden input tag list", () => { + expect(variant_tag_list.value).toBe("tag 1,tag 2,tag 3,new_tag"); + }); + + it("adds the new tag to the HTML tag list", () => { + const tagList = document.getElementsByClassName("tag-list")[0]; + + // 1 template + 3 tags + 1 new tag + expect(tagList.childElementCount).toBe(5); + }); + + it("clears the tag input", () => { + expect(variant_add_tag.value).toBe(""); + }); + + describe("with an empty new tag", () => { + it("doesn't add the tag", () => { + variant_add_tag.value = " "; + variant_add_tag.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + + const tagList = document.getElementsByClassName("tag-list")[0]; + + // 1 template + 3 tags + new tag (added in the beforeEach) + expect(tagList.childElementCount).toBe(5); + }); + }); + + describe("when tag already exist", () => { + beforeEach(() => { + // Trying to add an existing tag + variant_add_tag.value = "tag 2"; + variant_add_tag.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + }); + + it("doesn't add the tag", () => { + const tagList = document.getElementsByClassName("tag-list")[0]; + + // 1 template + 4 tags + expect(tagList.childElementCount).toBe(5); + expect(variant_add_tag.value).toBe("tag 2"); + }); + + it("highlights the new tag name in red", () => { + expect(variant_add_tag.classList).toContain("tag-error"); + }); + }); + }); + + describe("removeTag", () => { + beforeEach(() => { + const removeButtons = document.getElementsByClassName("remove-button"); + // Click on tag 2 + removeButtons[1].click(); + }); + + it("updates the hidden input tag list", () => { + expect(variant_tag_list.value).toBe("tag 1,tag 3"); + }); + + it("removes the tag from the HTML tag list", () => { + const tagList = document.getElementsByClassName("tag-list")[0]; + // 1 template + 2 tags + expect(tagList.childElementCount).toBe(3); + }); + }); + + describe("filterInput", () => { + it("removes comma from the tag input", () => { + variant_add_tag.value = "text"; + variant_add_tag.dispatchEvent(new KeyboardEvent("keyup", { key: "," })); + + expect(variant_add_tag.value).toBe("text"); + }); + + it("removes error highlight", () => { + variant_add_tag.value = "text"; + variant_add_tag.classList.add("tag-error"); + + variant_add_tag.dispatchEvent(new KeyboardEvent("keyup", { key: "a" })); + + expect(variant_add_tag.classList).not.toContain("tag-error"); + }); + }); +}); diff --git a/spec/jobs/open_order_cycle_job_spec.rb b/spec/jobs/open_order_cycle_job_spec.rb index c6d1d1d27e..bf7c84d5ab 100644 --- a/spec/jobs/open_order_cycle_job_spec.rb +++ b/spec/jobs/open_order_cycle_job_spec.rb @@ -71,7 +71,7 @@ RSpec.describe OpenOrderCycleJob do .and change { variant.on_demand }.to(true) .and change { variant.on_hand }.by(0) .and change { variant_discontinued.on_hand }.to(0) - .and query_database 58 + .and query_database 59 end end