From 7f16b6acde0c8af287a6783bd84a31118eeba6f6 Mon Sep 17 00:00:00 2001 From: Gaetan Craig-Riou Date: Wed, 24 Jul 2024 12:01:51 +1000 Subject: [PATCH] Update variant form and rip out angular --- .../variant_units_controller.js.coffee | 32 ---- .../spree/admin/variants/_form.html.haml | 161 ++++++++++------- .../controllers/edit_variant_controller.js | 169 ++++++++++++++++++ 3 files changed, 262 insertions(+), 100 deletions(-) delete mode 100644 app/assets/javascripts/admin/products/controllers/variant_units_controller.js.coffee create mode 100644 app/webpacker/controllers/edit_variant_controller.js diff --git a/app/assets/javascripts/admin/products/controllers/variant_units_controller.js.coffee b/app/assets/javascripts/admin/products/controllers/variant_units_controller.js.coffee deleted file mode 100644 index 91549bfc60..0000000000 --- a/app/assets/javascripts/admin/products/controllers/variant_units_controller.js.coffee +++ /dev/null @@ -1,32 +0,0 @@ -angular.module("admin.products").controller "variantUnitsCtrl", ($scope, VariantUnitManager, $timeout, UnitPrices, PriceParser) -> - - $scope.unitName = (scale, type) -> - VariantUnitManager.getUnitName(scale, type) - - $scope.$watchCollection "[unit_value_human, variant.price]", -> - $scope.processUnitPrice() - - $scope.processUnitPrice = -> - if ($scope.variant) - price = $scope.variant.price - scale = $scope.scale - unit_type = angular.element("#product_variant_unit").val() - if (unit_type != "items") - $scope.updateValue() - unit_value = $scope.unit_value - else - unit_value = 1 - variant_unit_name = angular.element("#product_variant_unit_name").val() - $scope.unit_price = UnitPrices.displayableUnitPrice(price, scale, unit_type, unit_value, variant_unit_name) - - $scope.scale = angular.element('#product_variant_unit_scale').val() - - $scope.updateValue = -> - unit_value_human = angular.element('#unit_value_human').val() - $scope.unit_value = bigDecimal.multiply(PriceParser.parse(unit_value_human), $scope.scale, 2) - - variant_unit_value = angular.element('#variant_unit_value').val() - $scope.unit_value_human = parseFloat(bigDecimal.divide(variant_unit_value, $scope.scale, 2)) - - $timeout -> $scope.processUnitPrice() - $timeout -> $scope.updateValue() diff --git a/app/views/spree/admin/variants/_form.html.haml b/app/views/spree/admin/variants/_form.html.haml index dcb4d68b6e..da6eff5e2b 100644 --- a/app/views/spree/admin/variants/_form.html.haml +++ b/app/views/spree/admin/variants/_form.html.haml @@ -1,83 +1,108 @@ -.label-block.left.six.columns.alpha{'ng-app' => 'admin.products', 'ng-controller' => 'variantUnitsCtrl'} - .field - = f.label :display_name, t('.display_name') - = f.text_field :display_name, class: "fullwidth", placeholder: t('.display_name_placeholder') - .field - = f.label :display_as, t('.display_as') - = f.text_field :display_as, class: "fullwidth", placeholder: t('.display_as_placeholder') +%div{'data-controller': "edit-variant"} + .label-block.left.six.columns.alpha + %script= render partial: "admin/shared/global_var_ofn", formats: :js, + locals: { name: :available_units_sorted, value: WeightsAndMeasures.available_units_sorted } - - if @product.variant_unit != 'items' - .field - = label_tag :unit_value_human, "#{t('admin.'+@product.variant_unit)} ({{unitName(#{@product.variant_unit_scale}, '#{@product.variant_unit}')}})" - = hidden_field_tag 'product_variant_unit_scale', @product.variant_unit_scale - = number_field_tag :unit_value_human, nil, {class: "fullwidth", step: 0.01, 'ng-model' => 'unit_value_human', 'ng-change' => 'updateValue()'} - = f.number_field :unit_value, {hidden: true, 'ng-value' => 'unit_value'} + %script= render partial: "admin/shared/global_var_ofn", formats: :js, + locals: { name: :currency_config, value: Api::CurrencyConfigSerializer.new({}) } - .field - = f.label :unit_description, t(:spree_admin_unit_description) - = f.text_field :unit_description, class: "fullwidth", placeholder: t('admin.products.unit_name_placeholder') + .field + = f.label :display_name, t('.display_name') + = f.text_field :display_name, class: "fullwidth", placeholder: t('.display_name_placeholder') - %div - .field - = f.label :sku, t('.sku') - = f.text_field :sku, class: 'fullwidth' - .field - = f.label :price, t('.price') - = f.text_field :price, class: 'fullwidth', "ng-model" => "variant.price", "ng-init" => "variant.price = '#{number_to_currency(@variant.price, unit: '')&.strip}'" - .field - = hidden_field_tag 'product_variant_unit', @product.variant_unit - = hidden_field_tag 'product_variant_unit_name', @product.variant_unit_name - = f.field_container :unit_price do - %div{style: "display: flex"} - = f.label :unit_price, t(".unit_price"), {style: "display: inline-block"} - %question-mark-with-tooltip{"question-mark-with-tooltip" => "_", - "question-mark-with-tooltip-append-to-body" => "true", - "question-mark-with-tooltip-placement" => "top", - "question-mark-with-tooltip-animation" => true, - key: "'js.admin.unit_price_tooltip'"} - %input{ "type" => "text", "id" => "variant_unit_price", "name" => "variant[unit_price]", - "class" => 'fullwidth', "disabled" => true, "ng-model" => "unit_price"} - %div{style: "color: black"} - = t("spree.admin.products.new.unit_price_legend") - %div{ 'set-on-demand' => '' } - .field.checkbox - %label - = f.check_box :on_demand - = t(:on_demand) - %div{'ofn-with-tip' => t('admin.products.variants.to_order_tip')} - %a= t('admin.whats_this') + .field{ 'data-controller': 'toggle-control', 'data-toggle-control-match-value': 'items' } + -#TODO translation + = f.label :unit_scale, raw(t(:unit_scale) + content_tag(:span, ' *', :class => 'required')) + = f.hidden_field :variant_unit + = f.hidden_field :variant_unit_scale + = f.select :variant_unit_with_scale, + options_for_select(WeightsAndMeasures.variant_unit_options, @variant.variant_unit_with_scale), + { include_blank: true }, + { class: "fullwidth no-input", 'aria-label': t('admin.products_page.columns.unit_scale'), data: { "controller": "tom-select", "tom-select-options-value": '{ "plugins": [] }', action: "change->toggle-control#displayIfMatch" } } + = error_message_on @variant, :variant_unit, 'data-toggle-control-target': 'control' .field - = f.label :on_hand, t(:on_hand) - .fullwidth - = f.text_field :on_hand + = f.text_field :variant_unit_name, 'aria-label': t('items'), 'data-toggle-control-target': 'control', style: (@variant.variant_unit == "items" ? "" : "display: none") + = error_message_on @variant, :variant_unit_name, 'data-toggle-control-target': 'control' + + .field.popout{'data-controller': "popout", 'data-popout-update-display-value': "false"} + -#TODO translation + = f.label :unit, raw(t(:unit) + content_tag(:span, ' *', :class => 'required')) + = f.button :unit_to_display, class: "popout__button", 'aria-label': t('admin.products_page.columns.unit'), 'data-popout-target': "button" do + = @variant.unit_to_display # Show the generated summary of unit values + %div.popout__container{ style: 'display: none;', 'data-controller': 'toggle-control', 'data-popout-target': "dialog" } + .field + -# Show a composite field for unit_value and unit_description + = f.hidden_field :unit_value + = f.hidden_field :unit_description + -# todo: create a method for value_with_description + = f.text_field :unit_value_with_description, + value: [number_with_precision((@variant.unit_value || 1) / (@variant.variant_unit_scale || 1), precision: nil, strip_insignificant_zeros: true), @variant.unit_description].compact_blank.join(" "), + 'aria-label': t('admin.products_page.columns.unit_value'), required: true + .field + = f.label :display_as, t('admin.products_page.columns.display_as') + = f.text_field :display_as, placeholder: VariantUnits::OptionValueNamer.new(@variant).name + = error_message_on @variant, :unit_value -.right.six.columns.omega.label-block - - if @product.variant_unit != 'weight' + %div + .field + = f.label :sku, t('.sku') + = f.text_field :sku, class: 'fullwidth' + .field + = f.label :price, raw(t(:price) + content_tag(:span, ' *', :class => 'required')) + = f.text_field :price, class: 'fullwidth', value: number_to_currency(@variant.price, unit: '')&.strip + .field + = hidden_field_tag 'variant_variant_unit', @variant.variant_unit + = hidden_field_tag 'variant_variant_unit_name', @variant.variant_unit_name + = f.field_container :unit_price do + %div{style: "display: flex"} + = f.label :unit_price, t(".unit_price"), {style: "display: inline-block"} + %question-mark-with-tooltip{"question-mark-with-tooltip" => "_", + "question-mark-with-tooltip-append-to-body" => "true", + "question-mark-with-tooltip-placement" => "top", + "question-mark-with-tooltip-animation" => true, + key: "'js.admin.unit_price_tooltip'"} + %input{ "type" => "text", "id" => "variant_unit_price", "name" => "variant[unit_price]", "class" => 'fullwidth', "disabled" => true} + %div{style: "color: black"} + = t("spree.admin.products.new.unit_price_legend") + %div{ 'set-on-demand' => '' } + .field.checkbox + %label + = f.check_box :on_demand + = t(:on_demand) + - #TODO tooltip is broken + %div{'ofn-with-tip' => t('admin.products.variants.to_order_tip')} + %a= t('admin.whats_this') + .field + = f.label :on_hand, t(:on_hand) + .fullwidth + = f.text_field :on_hand + + .right.six.columns.omega.label-block .field = f.label 'weight', t(:weight)+' (kg)' - value = number_with_precision(@variant.weight, precision: 3) = f.number_field 'weight', value: value, class: 'fullwidth', step: 0.001 - - [:height, :width, :depth].each do |field| + - [:height, :width, :depth].each do |field| + .field + = f.label field, t(field) + - value = number_with_precision(@variant.send(field), precision: 2) + = f.number_field field, value: value, class: 'fullwidth', step: 0.01 + .field - = f.label field, t(field) - - value = number_with_precision(@variant.send(field), precision: 2) - = f.number_field field, value: value, class: 'fullwidth', step: 0.01 + = f.label :tax_category, t(:tax_category), for: :tax_category_id + = f.collection_select(:tax_category_id, @tax_categories, :id, :name, { include_blank: t(:none) }, { class: 'select2 fullwidth' }) - .field - = f.label :tax_category, t(:tax_category), for: :tax_category_id - = f.collection_select(:tax_category_id, @tax_categories, :id, :name, { include_blank: t(:none) }, { class: 'select2 fullwidth' }) + .field + = f.label :shipping_category_id, t(:shipping_categories) + = f.collection_select(:shipping_category_id, @shipping_categories, :id, :name, {}, { class: 'select2 fullwidth' }) - .field - = f.label :shipping_category_id, t(:shipping_categories) - = f.collection_select(:shipping_category_id, @shipping_categories, :id, :name, {}, { class: 'select2 fullwidth' }) + .field + = f.label :primary_taxon, t('spree.admin.products.primary_taxon_form.product_category') + = f.collection_select(:primary_taxon_id, Spree::Taxon.order(:name), :id, :name, { include_blank: true }, { class: "select2 fullwidth" }) - .field - = f.label :primary_taxon, t('spree.admin.products.primary_taxon_form.product_category') - = f.collection_select(:primary_taxon_id, Spree::Taxon.order(:name), :id, :name, { include_blank: true }, { class: "select2 fullwidth" }) + .field + = f.label :supplier, t(:spree_admin_supplier) + = f.collection_select(:supplier_id, @producers, :id, :name, {:include_blank => true}, {:class => "select2 fullwidth"}) - .field - = f.label :supplier, t(:spree_admin_supplier) - = f.collection_select(:supplier_id, @producers, :id, :name, {:include_blank => true}, {:class => "select2 fullwidth"}) - -.clear + .clear diff --git a/app/webpacker/controllers/edit_variant_controller.js b/app/webpacker/controllers/edit_variant_controller.js new file mode 100644 index 0000000000..57b8a0ae85 --- /dev/null +++ b/app/webpacker/controllers/edit_variant_controller.js @@ -0,0 +1,169 @@ +import { Controller } from "stimulus"; +import OptionValueNamer from "js/services/option_value_namer"; +import UnitPrices from "js/services/unit_prices"; + +// Dynamically update related variant fields +// +// TODO refactor so we can extract what's common with Bulk product page +export default class EditVariantController extends Controller { + connect() { + this.unitPrices = new UnitPrices(); + // idea: create a helper that includes a nice getter/setter for Rails model attr values, just pass it the attribute name. + // It could automatically find (and cache a ref to) each dom element and get/set the values. + this.variantUnit = this.element.querySelector('[id="variant_variant_unit"]'); + this.variantUnitScale = this.element.querySelector('[id="variant_variant_unit_scale"]'); + this.variantUnitName = this.element.querySelector('[id="variant_variant_unit_name"]'); + this.variantUnitWithScale = this.element.querySelector( + '[id="variant_variant_unit_with_scale"]', + ); + this.variantPrice = this.element.querySelector('[id="variant_price"]'); + + // on variant_unit_with_scale changed; update variant_unit and variant_unit_scale + this.variantUnitWithScale.addEventListener("change", this.#updateUnitAndScale.bind(this), { + passive: true, + }); + + this.unitValue = this.element.querySelector('[id="variant_unit_value"]'); + this.unitDescription = this.element.querySelector('[id="variant_unit_description"]'); + this.unitValueWithDescription = this.element.querySelector( + '[id="variant_unit_value_with_description"]', + ); + this.displayAs = this.element.querySelector('[id="variant_display_as"]'); + this.unitToDisplay = this.element.querySelector('[id="variant_unit_to_display"]'); + + // on unit changed; update display_as:placeholder and unit_to_display + [this.variantUnit, this.variantUnitScale, this.variantUnitName].forEach((element) => { + element.addEventListener("change", this.#unitChanged.bind(this), { passive: true }); + }); + this.variantUnitName.addEventListener("input", this.#unitChanged.bind(this), { passive: true }); + + // on unit_value_with_description changed; update unit_value and unit_description + // on unit_value and/or unit_description changed; update display_as:placeholder and unit_to_display + this.unitValueWithDescription.addEventListener("input", this.#unitChanged.bind(this), { + passive: true, + }); + + // on display_as changed; update unit_to_display + // TODO: optimise to avoid unnecessary OptionValueNamer calc + this.displayAs.addEventListener("input", this.#updateUnitDisplay.bind(this), { passive: true }); + + // update Unit price when variant_unit_with_scale or price changes + [this.variantUnitWithScale, this.variantPrice].forEach((element) => { + element.addEventListener("change", this.#processUnitPrice.bind(this), { passive: true }); + }); + this.unitValueWithDescription.addEventListener("input", this.#processUnitPrice.bind(this), { + passive: true, + }); + + // on variantUnit change we need to check if weight needs to be toggled + this.variantUnit.addEventListener("change", this.#toggleWeight.bind(this), { passive: true }); + + // update unit price on page load + this.#processUnitPrice(); + this.#toggleWeight(); + } + + disconnect() { + // Make sure to clean up anything that happened outside + } + + // private + + // Extract variant_unit and variant_unit_scale from dropdown variant_unit_with_scale, + // and update hidden product fields + #unitChanged(event) { + //Hmm in hindsight the logic in product_controller should be inn this controller already. then we can do everything in one event, and store the generated name in an instance variable. + this.#extractUnitValues(); + this.#updateUnitDisplay(); + } + + // Extract unit_value and unit_description + #extractUnitValues() { + // Extract a number (optional) and text value, separated by a space. + const match = this.unitValueWithDescription.value.match(/^([\d\.\,]+(?= |$)|)( |)(.*)$/); + if (match) { + let unit_value = parseFloat(match[1].replace(",", ".")); + unit_value = isNaN(unit_value) ? null : unit_value; + unit_value *= this.variantUnitScale.value ? this.variantUnitScale.value : 1; // Normalise to default scale + + this.unitValue.value = unit_value; + this.unitDescription.value = match[3]; + } + } + + // Update display_as placeholder and unit_to_display + #updateUnitDisplay() { + const unitDisplay = new OptionValueNamer(this.#variant()).name(); + this.displayAs.placeholder = unitDisplay; + this.unitToDisplay.textContent = this.displayAs.value || unitDisplay; + } + + // A representation of the variant model to satisfy OptionValueNamer. + #variant() { + return { + unit_value: parseFloat(this.unitValue.value), + unit_description: this.unitDescription.value, + variant_unit: this.variantUnit.value, + variant_unit_scale: parseFloat(this.variantUnitScale.value), + variant_unit_name: this.variantUnitName.value, + }; + } + + // Extract variant_unit and variant_unit_scale from dropdown variant_unit_with_scale, + // and update hidden product fields + #updateUnitAndScale(event) { + const variant_unit_with_scale = this.variantUnitWithScale.value; + const match = variant_unit_with_scale.match(/^([^_]+)_([\d\.]+)$/); // eg "weight_1000" + + if (match) { + this.variantUnit.value = match[1]; + this.variantUnitScale.value = parseFloat(match[2]); + } else { + // "items" + this.variantUnit.value = variant_unit_with_scale; + this.variantUnitScale.value = ""; + } + this.variantUnit.dispatchEvent(new Event("change")); + this.variantUnitScale.dispatchEvent(new Event("change")); + } + + #processUnitPrice() { + const unit_type = this.variantUnit.value; + + // TODO double check this + let unit_value = 1; + if (unit_type != "items") { + unit_value = this.unitValue.value; + } + + const unit_price = this.unitPrices.displayableUnitPrice( + this.variantPrice.value, + parseFloat(this.variantUnitScale.value), + unit_type, + unit_value, + this.variantUnitName.value, + ); + + this.element.querySelector('[id="variant_unit_price"]').value = unit_price; + } + + #toggleWeight() { + let display = "block"; + if (this.variantUnit.value === "weight") { + display = "none"; + } + + this.weight = this.element.querySelector('[id="variant_weight"]'); + this.weight.parentElement.style.display = display; + } + + //#showWeight() { + // this.weight = this.element.querySelector('[id="variant_weight"]'); + // this.weight.parentElement.style.display= "block" + //} + + //#hideWeight() { + // this.weight = this.element.querySelector('[id="variant_weight"]'); + // this.weight.parentElement.style.display= "none" + //} +}