From 4ddb2ff1e97c0a1d902bfe8780ceda6bacc0d5bc Mon Sep 17 00:00:00 2001 From: David Cook Date: Thu, 21 Mar 2024 21:38:44 +1100 Subject: [PATCH] Generate unit display with OptionValueNamer --- .../controllers/variant_controller.js | 23 ++++- .../js/services/option_value_namer.js | 94 +++++++++++++++++++ .../js/services/variant_unit_manager.js | 93 ++++++++++++++++++ .../system/admin/products_v3/products_spec.rb | 49 +++++++--- 4 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 app/webpacker/js/services/option_value_namer.js create mode 100644 app/webpacker/js/services/variant_unit_manager.js diff --git a/app/webpacker/controllers/variant_controller.js b/app/webpacker/controllers/variant_controller.js index ef007c442e..1ab401163a 100644 --- a/app/webpacker/controllers/variant_controller.js +++ b/app/webpacker/controllers/variant_controller.js @@ -1,4 +1,5 @@ import { Controller } from "stimulus"; +import OptionValueNamer from "js/services/option_value_namer"; // Dynamically update related variant fields // @@ -22,6 +23,7 @@ export default class VariantController extends Controller { [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 @@ -29,7 +31,8 @@ export default class VariantController extends Controller { passive: true, }); - // on display_as changed; update unit_to_display (how does this relate to unit_presentation?) + // 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 }); } @@ -42,7 +45,6 @@ export default class VariantController extends Controller { // Extract variant_unit and variant_unit_scale from dropdown variant_unit_with_scale, // and update hidden product fields #unitChanged(event) { - //todo: deduplicate events. //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(); @@ -64,10 +66,21 @@ export default class VariantController extends Controller { // Update display_as placeholder and unit_to_display #updateUnitDisplay() { - // const unitDisplay = OptionValueNamer... - const unitDisplay = - this.unitValueWithDescription.value + " " + (this.variantUnitName.value || "~g"); //To Remove: DEMO only + 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, + product: { + variant_unit: this.variantUnit.value, + variant_unit_scale: parseFloat(this.variantUnitScale.value), + variant_unit_name: this.variantUnitName.value, + }, + }; + } } diff --git a/app/webpacker/js/services/option_value_namer.js b/app/webpacker/js/services/option_value_namer.js new file mode 100644 index 0000000000..48564fecb7 --- /dev/null +++ b/app/webpacker/js/services/option_value_namer.js @@ -0,0 +1,94 @@ +import VariantUnitManager from "js/services/variant_unit_manager"; + +// Javascript clone of VariantUnits::OptionValueNamer, for bulk product editing. +export default class OptionValueNamer { + constructor(variant) { + this.variant = variant; + } + + name() { + const [value, unit] = this.option_value_value_unit(); + const separator = this.value_scaled() ? '' : ' '; + const name_fields = []; + if (value && unit) { + name_fields.push(`${value}${separator}${unit}`); + } + if (this.variant.unit_description) { + name_fields.push(this.variant.unit_description); + } + return name_fields.join(' '); + } + + value_scaled() { + return !!this.variant.product.variant_unit_scale; + } + + option_value_value_unit() { + let value, unit_name; + if (this.variant.unit_value) { + if (this.variant.product.variant_unit === "weight" || this.variant.product.variant_unit === "volume") { + [value, unit_name] = this.option_value_value_unit_scaled(); + } else { + value = this.variant.unit_value; + unit_name = this.pluralize(this.variant.product.variant_unit_name, value); + } + if (value == parseInt(value, 10)) { + value = parseInt(value, 10); + } + } else { + value = unit_name = null; + } + return [value, unit_name]; + } + + pluralize(unit_name, count) { + if (count == null) { + return unit_name; + } + const unit_key = this.unit_key(unit_name); + if (!unit_key) { + return unit_name; + } + return I18n.t(["inflections", unit_key], { + count: count, + defaultValue: unit_name + }); + } + + unit_key(unit_name) { + if (!I18n.unit_keys) { + I18n.unit_keys = {}; + for (const [key, translations] of Object.entries(I18n.t("inflections"))) { + for (const [quantifier, translation] of Object.entries(translations)) { + I18n.unit_keys[translation.toLowerCase()] = key; + } + } + } + return I18n.unit_keys[unit_name.toLowerCase()]; + } + + option_value_value_unit_scaled() { + const [unit_scale, unit_name] = this.scale_for_unit_value(); + const value = Math.round((this.variant.unit_value / unit_scale) * 100) / 100; + return [value, unit_name]; + } + + scale_for_unit_value() { + // Find the largest available and compatible unit where unit_value comes + // to >= 1 when expressed in it. + // If there is none available where this is true, use the smallest + // available unit. + const product = this.variant.product; + const scales = VariantUnitManager.compatibleUnitScales(product.variant_unit_scale, product.variant_unit); + const variantUnitValue = this.variant.unit_value; + + // sets largestScale = last element in filtered scales array + const largestScale = scales.filter(s => variantUnitValue / s >= 1).slice(-1)[0]; + if (largestScale) { + return [largestScale, VariantUnitManager.getUnitName(largestScale, product.variant_unit)]; + } else { + return [scales[0], VariantUnitManager.getUnitName(scales[0], product.variant_unit)]; + } + } +} + diff --git a/app/webpacker/js/services/variant_unit_manager.js b/app/webpacker/js/services/variant_unit_manager.js new file mode 100644 index 0000000000..71859b2e30 --- /dev/null +++ b/app/webpacker/js/services/variant_unit_manager.js @@ -0,0 +1,93 @@ +// todo load availableUnits. Hmm why not just load from the dropdown? +export default class VariantUnitManager { + static availableUnits = 'g,kg,T,mL,L,gal,kL'; + // todo: load units from Ruby also? + static units = { + weight: { + 0.001: { + name: 'mg', + system: 'metric' + }, + 1.0: { + name: 'g', + system: 'metric' + }, + 1000.0: { + name: 'kg', + system: 'metric' + }, + 1000000.0: { + name: 'T', + system: 'metric' + }, + 453.6: { + name: 'lb', + system: 'imperial' + }, + 28.35: { + name: 'oz', + system: 'imperial' + } + }, + volume: { + 0.001: { + name: 'mL', + system: 'metric' + }, + 0.01: { + name: 'cL', + system: 'metric' + }, + 0.1: { + name: 'dL', + system: 'metric' + }, + 1.0: { + name: 'L', + system: 'metric' + }, + 1000.0: { + name: 'kL', + system: 'metric' + }, + 4.54609: { + name: 'gal', + system: 'metric' + } + }, + items: { + 1: { + name: 'items' + } + } + }; + + static getUnitName = (scale, unitType) => { + if (this.units[unitType][scale]) { + return this.units[unitType][scale]['name']; + } else { + return ''; + } + }; + + // filter by system and format + static compatibleUnitScales = (scale, unitType) => { + const scaleSystem = this.units[unitType][scale]['system']; + if (this.availableUnits) { + const available = this.availableUnits.split(","); + return Object.entries(this.units[unitType]) + .filter(([scale, scaleInfo]) => { + return scaleInfo['system'] == scaleSystem && available.includes(scaleInfo['name']); + }) + .map(([scale, _]) => parseFloat(scale)) + .sort((a, b) => a - b); + } else { + return Object.entries(this.units[unitType]) + .filter(([scale, scaleInfo]) => { + return scaleInfo['system'] == scaleSystem; + }) + .map(([scale, _]) => parseFloat(scale)) + .sort((a, b) => a - b); + } + }; +} diff --git a/spec/system/admin/products_v3/products_spec.rb b/spec/system/admin/products_v3/products_spec.rb index a7554c1121..c9b00987f6 100644 --- a/spec/system/admin/products_v3/products_spec.rb +++ b/spec/system/admin/products_v3/products_spec.rb @@ -258,23 +258,38 @@ describe 'As an admin, I can manage products', feature: :admin_style_v3 do end end - it "saves a custom item unit name" do - within row_containing_name("Apples") do - tomselect_select "Items", from: "Unit scale" - fill_in "Items", with: "box" + describe "Changing unit scale" do + it "saves unit values using the new scale" do + within row_containing_name("Medium box") do + expect(page).to have_button "Unit", text: "1g" + end + within row_containing_name("Apples") do + tomselect_select "Weight (kg)", from: "Unit scale" + end + within row_containing_name("Medium box") do + # New scale is visible immediately + expect(page).to have_button "Unit", text: "1kg" + end end - expect { - click_button "Save changes" + it "saves a custom item unit name" do + within row_containing_name("Apples") do + tomselect_select "Items", from: "Unit scale" + fill_in "Items", with: "box" + end - expect(page).to have_content "Changes saved" - product_a.reload - }.to change{ product_a.variant_unit }.to("items") - .and change{ product_a.variant_unit_name }.to("box") + expect { + click_button "Save changes" - within row_containing_name("Apples") do - pending - expect(page).to have_content "Items (box)" + expect(page).to have_content "Changes saved" + product_a.reload + }.to change{ product_a.variant_unit }.to("items") + .and change{ product_a.variant_unit_name }.to("box") + + within row_containing_name("Apples") do + pending "#12005" + expect(page).to have_content "Items (box)" + end end end @@ -284,6 +299,10 @@ describe 'As an admin, I can manage products', feature: :admin_style_v3 do within row_containing_name("Medium box") do click_on "Unit" # activate popout fill_in "Unit value", with: "1000 boxed" # 1000 grams + + find_field("Price").click # de-activate popout + # unit value has been parsed and displayed with unit + expect(page).to have_button "Unit", text: "1kg boxed" end expect { @@ -302,6 +321,7 @@ describe 'As an admin, I can manage products', feature: :admin_style_v3 do it "saves a custom variant unit display name" do within row_containing_name("Medium box") do + click_on "Unit" # activate popout fill_in "Display unit as", with: "250g box" end @@ -313,8 +333,9 @@ describe 'As an admin, I can manage products', feature: :admin_style_v3 do }.to change{ variant_a1.unit_to_display }.to("250g box") within row_containing_name("Medium box") do - expect(page).to have_field "Display unit as", with: "250g box" expect(page).to have_button "Unit", text: "250g box" + click_on "Unit" + expect(page).to have_field "Display unit as", with: "250g box" end end end