Skip to content

Commit 08d3048

Browse files
committed
Pre-fill new variant fields when adding a variant
When a hub manager adds a variant to a product, copy the producer, category, tax category, unit, unit value and price from the most recently created variant, and default the quantity to 0. Previously these fields were blank, which caused confusing save errors when the required producer, category and unit fields were left empty — behaviour that regressed when those fields moved from the product to the variant. Defaulting the quantity to 0 (and not copying the on-demand flag) leaves new variants out of stock until the user sets a quantity, preventing accidental sales.
1 parent 056a883 commit 08d3048

8 files changed

Lines changed: 211 additions & 40 deletions

File tree

app/helpers/admin/products_helper.rb

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,26 @@ def product_image_form_path(product)
1010
end
1111
end
1212

13+
NEW_VARIANT_TEMPLATE_FIELDS = %i[
14+
tax_category_id
15+
primary_taxon_id
16+
supplier_id
17+
variant_unit
18+
variant_unit_scale
19+
variant_unit_name
20+
unit_value
21+
price
22+
].freeze
23+
1324
def prepare_new_variant(product, producer_id = nil)
1425
product.variants.build do |new_variant|
15-
new_variant.supplier_id = producer_id
16-
new_variant.tax_category_id = product.variants.first.tax_category_id
26+
template = product.variants.reject(&:new_record?).last
27+
copy_template_fields(template, new_variant) if template
28+
new_variant.on_hand_desired = 0
29+
# Integer producer_id explicitly overrides the template's supplier_id.
30+
# The view passes an AR relation (allowed_producers), not an ID, so this
31+
# guard ensures only a real ID overrides the copied value.
32+
new_variant.supplier_id = producer_id if producer_id.is_a?(Integer)
1733
end
1834
end
1935

@@ -57,6 +73,16 @@ def managed_product_enterprises
5773
.managed_product_enterprises
5874
end
5975

76+
private
77+
78+
def copy_template_fields(template, new_variant)
79+
NEW_VARIANT_TEMPLATE_FIELDS.each do |field|
80+
next unless template.respond_to?(field) && new_variant.respond_to?(:"#{field}=")
81+
82+
new_variant.public_send(:"#{field}=", template.public_send(field))
83+
end
84+
end
85+
6086
# Query only name of the model to avoid loading the whole record
6187
def selected_option(id, model)
6288
return [] unless id

app/views/admin/products_v3/_product_variant_row.html.haml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
= form.fields_for("products", product, index: product_index) do |product_form|
33
%tbody.relaxed{ id: dom_id(product), data: { 'record-id': product_form.object.id,
44
controller: "nested-form product",
5-
action: 'rails-nested-form:add->bulk-form#registerElements rails-nested-form:remove->bulk-form#toggleFormChanged' },
5+
action: 'rails-nested-form:add->bulk-form#registerElements rails-nested-form:add->bulk-form#toggleFormChanged rails-nested-form:remove->bulk-form#toggleFormChanged' },
66
class: (defined?(should_slide_in) && should_slide_in) ? 'slide-in' : '' }
77
%tr
88
= render partial: 'product_row', locals: { f: product_form, product:, product_index: }

app/webpacker/controllers/bulk_form_controller.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,15 @@ export default class BulkFormController extends Controller {
7272
const changedRecordCount = Object.values(this.recordElements).filter((elements) =>
7373
elements.some(this.#checkIsChanged.bind(this)),
7474
).length;
75-
this.formChanged = changedRecordCount > 0 || this.errorValue;
75+
76+
// New (unsaved) variants have an empty hidden id field (no value attribute, or value="").
77+
// Their pre-filled values look "unchanged" to #isChanged because Rails bakes the defaults
78+
// into the HTML, so we detect them separately and treat their presence as a pending change.
79+
const hasNewVariants = Array.from(
80+
this.form.querySelectorAll('input[type="hidden"][name*="variants_attributes"][name$="[id]"]'),
81+
).some((el) => !el.value);
82+
83+
this.formChanged = changedRecordCount > 0 || this.errorValue || hasNewVariants;
7684

7785
// Show actions
7886
this.hasActionsTarget && this.actionsTarget.classList.toggle("hidden", !this.formChanged);
@@ -103,6 +111,12 @@ export default class BulkFormController extends Controller {
103111
this.variantUnits = this.element.querySelectorAll("button.popout__button");
104112
this.variantUnits.forEach((element) => {
105113
if (element.textContent == "") {
114+
// If the row already has a unit type set (e.g. from pre-filled defaults or a prior
115+
// TomSelect choice), skip the popout — the unit type is known, and the unit value
116+
// will be caught by server-side validation with a clear error message.
117+
const row = element.closest("tr");
118+
const variantUnitField = row?.querySelector('input[type="hidden"][name$="[variant_unit]"]');
119+
if (variantUnitField?.value) return;
106120
element.click();
107121
}
108122
});

app/webpacker/controllers/tom_select_controller.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export default class extends Controller {
1010
};
1111

1212
connect(options = {}) {
13+
// Capture the select's current value before TomSelect replaces the element.
14+
// For non-remote selects, passing this as `items` tells TomSelect to restore
15+
// the pre-selected option during its silent constructor phase (isSetup = false),
16+
// so no input/change events fire on the underlying select.
17+
const initialValue = !this.remoteUrlValue ? this.element.value : null;
18+
1319
let tomSelectOptions = {
1420
maxItems: 1,
1521
maxOptions: null,
@@ -19,6 +25,7 @@ export default class extends Controller {
1925
onItemAdd: function () {
2026
this.setTextboxValue("");
2127
},
28+
...(initialValue ? { items: [initialValue] } : {}),
2229
...this.optionsValue,
2330
...options,
2431
};

spec/helpers/admin/products_helper_spec.rb

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,74 @@
4747

4848
describe '#prepare_new_variant' do
4949
let(:zone) { create(:zone_with_member) }
50+
let(:taxon) { create(:taxon) }
51+
let(:supplier) { create(:supplier_enterprise) }
5052
let(:product) {
5153
create(:taxed_product, zone:, price: 12.54, tax_rate_amount: 0,
5254
included_in_price: true)
5355
}
5456

55-
context 'when tax category is present for first varient' do
56-
it 'sets tax category for new variant' do
57-
first_variant_tax_id = product.variants.first.tax_category_id
58-
expect(helper.prepare_new_variant(product, []).tax_category_id).to eq(first_variant_tax_id)
57+
before do
58+
product.variants.last.update!(
59+
primary_taxon: taxon,
60+
supplier:,
61+
variant_unit: "weight",
62+
variant_unit_scale: 1000.0,
63+
unit_value: 1000.0,
64+
price: 9.99,
65+
)
66+
end
67+
68+
it 'copies tax category from the last variant' do
69+
expect(helper.prepare_new_variant(product).tax_category_id)
70+
.to eq(product.variants.last.tax_category_id)
71+
end
72+
73+
it 'copies category (primary taxon) from the last variant' do
74+
expect(helper.prepare_new_variant(product).primary_taxon_id).to eq(taxon.id)
75+
end
76+
77+
it 'copies unit type from the last variant' do
78+
new_variant = helper.prepare_new_variant(product)
79+
expect(new_variant.variant_unit).to eq("weight")
80+
expect(new_variant.variant_unit_scale).to eq(1000.0)
81+
end
82+
83+
it 'copies unit value from the last variant so the unit field renders non-empty' do
84+
expect(helper.prepare_new_variant(product).unit_value).to eq(1000.0)
85+
end
86+
87+
it 'copies price from the last variant' do
88+
expect(helper.prepare_new_variant(product).price).to eq(9.99)
89+
end
90+
91+
it 'copies producer (supplier) from the last variant' do
92+
expect(helper.prepare_new_variant(product).supplier_id).to eq(supplier.id)
93+
end
94+
95+
it 'sets on_hand_desired to 0' do
96+
expect(helper.prepare_new_variant(product).on_hand_desired).to eq(0)
97+
end
98+
99+
it 'does not copy on_demand, so new variants default to out of stock' do
100+
expect(helper.prepare_new_variant(product).on_demand_desired).to be_falsey
101+
end
102+
103+
it 'overrides producer with an explicit integer producer_id' do
104+
other_supplier = create(:supplier_enterprise)
105+
expect(helper.prepare_new_variant(product, other_supplier.id).supplier_id)
106+
.to eq(other_supplier.id)
107+
end
108+
109+
context 'when the product has no existing variants' do
110+
let(:product) { create(:product) }
111+
112+
before { product.variants.destroy_all }
113+
114+
it 'returns a variant with only supplier_id set' do
115+
new_variant = helper.prepare_new_variant(product, supplier.id)
116+
expect(new_variant.supplier_id).to eq(supplier.id)
117+
expect(new_variant.primary_taxon_id).to be_nil
59118
end
60119
end
61120
end

spec/javascripts/stimulus/bulk_form_controller_test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,91 @@ describe("BulkFormController", () => {
306306
});
307307
});
308308

309+
describe("New unsaved variant detection", () => {
310+
beforeEach(() => {
311+
document.body.innerHTML = `
312+
<form id="form" data-controller="bulk-form"
313+
data-action="custom-event->bulk-form#toggleFormChanged">
314+
<div id="actions" data-bulk-form-target="actions" class="hidden"></div>
315+
<table class="products">
316+
<div data-record-id="1">
317+
<input id="input1" type="text" value="initial">
318+
</div>
319+
</table>
320+
</form>
321+
`;
322+
});
323+
324+
it("shows save button when a new (unsaved) variant id field is present (no value attribute)", () => {
325+
// Rails renders new-record hidden id fields with no value attribute
326+
input1.insertAdjacentHTML(
327+
"afterend",
328+
'<input id="new_variant_id" type="hidden" name="form[products][0][variants_attributes][1][id]">',
329+
);
330+
331+
form.dispatchEvent(new Event("custom-event"));
332+
333+
expect(actions.classList).not.toContain("hidden");
334+
});
335+
336+
it("does not show save button when all variant ids are present (existing variants)", () => {
337+
input1.insertAdjacentHTML(
338+
"afterend",
339+
'<input id="existing_variant_id" type="hidden" name="form[products][0][variants_attributes][0][id]" value="42">',
340+
);
341+
342+
form.dispatchEvent(new Event("custom-event"));
343+
344+
expect(actions.classList).toContain("hidden");
345+
});
346+
});
347+
348+
describe("popoutEmptyVariantUnit", () => {
349+
// Trigger the action via a custom event wired to popoutEmptyVariantUnit, the same
350+
// pattern used by the rest of this test file to invoke controller actions.
351+
const triggerPopout = () => form.dispatchEvent(new Event("trigger-popout"));
352+
353+
beforeEach(() => {
354+
document.body.innerHTML = `
355+
<form id="form" data-controller="bulk-form"
356+
data-action="trigger-popout->bulk-form#popoutEmptyVariantUnit">
357+
<div id="actions" data-bulk-form-target="actions" class="hidden"></div>
358+
<table class="products">
359+
<tr>
360+
<td class="col-unit_scale">
361+
<input id="variant_unit_field" type="hidden"
362+
name="form[products][0][variants_attributes][0][variant_unit]" value="">
363+
</td>
364+
<td class="col-unit">
365+
<button id="unit_popout_button" class="popout__button"></button>
366+
</td>
367+
</tr>
368+
</table>
369+
</form>
370+
`;
371+
});
372+
373+
it("clicks the button when its text is empty and variant_unit is not set", () => {
374+
const clickSpy = jest.spyOn(unit_popout_button, "click");
375+
triggerPopout();
376+
expect(clickSpy).toHaveBeenCalled();
377+
});
378+
379+
it("does not click the button when variant_unit is already set (pre-filled variant)", () => {
380+
variant_unit_field.value = "weight";
381+
const clickSpy = jest.spyOn(unit_popout_button, "click");
382+
triggerPopout();
383+
expect(clickSpy).not.toHaveBeenCalled();
384+
});
385+
386+
it("does not click the button when its text is not empty", () => {
387+
unit_popout_button.textContent = "100 g";
388+
const clickSpy = jest.spyOn(unit_popout_button, "click");
389+
triggerPopout();
390+
expect(clickSpy).not.toHaveBeenCalled();
391+
});
392+
});
393+
309394
// unable to test disconnect at this stage
310395
// describe("disconnect()", () => {
311396
// it("resets other elements", () => {

spec/system/admin/products_v3/create_spec.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,19 @@
4848
expect(page).to have_content "New variant"
4949
end
5050

51-
it "has the empty unit value for the new variant display_as by default" do
51+
it "pre-fills the unit from the last variant on a new variant row" do
5252
new_variant_button.click
5353

5454
within new_variant_row do
5555
unit_button = find('button[aria-label="Unit"]')
56-
expect(unit_button.text.strip).to eq('')
57-
5856
unit_button.click
59-
expect(page).to have_field "Display unit as", placeholder: ""
57+
58+
# The new variant inherits the unit from the existing variant (1g):
59+
# the unit value is pre-filled so the user doesn't get a "can't be blank"
60+
# error on save. The display_as field itself stays empty (the inherited
61+
# unit shows as its placeholder) so the user can still override it.
62+
expect(page).to have_field "Unit value", with: "1"
63+
expect(page).to have_field "Display unit as", with: "", placeholder: "1g"
6064
end
6165
end
6266

spec/system/admin/products_v3/update_spec.rb

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,8 @@
505505
within new_variant_row do
506506
fill_in "Name", with: "N" * 256 # too long
507507
fill_in "SKU", with: "n" * 256
508-
# didn't fill_in "Unit", can't be blank
508+
# Unit, producer and category are pre-filled from the existing variant,
509+
# so the new row is valid apart from the too-long Name and SKU above.
509510
fill_in "Price", with: "10.25" # valid
510511
end
511512
end
@@ -518,30 +519,6 @@
518519
fill_in "Price", with: "10.25"
519520
end
520521

521-
# Client side validation
522-
click_button "Save changes"
523-
within new_variant_row do
524-
expect_browser_validation('select[aria-label="Unit scale"]')
525-
end
526-
527-
# Fix error
528-
within new_variant_row do
529-
tomselect_select("Weight (kg)", from: "Unit scale")
530-
end
531-
532-
# Client side validation
533-
click_button "Save changes"
534-
within new_variant_row do
535-
# In CI we get "Please fill out this field." and locally we get
536-
# "Please fill in this field."
537-
expect_browser_validation('input[aria-label="Unit value"]')
538-
end
539-
540-
# Fix error
541-
within new_variant_row do
542-
fill_in "Unit value", with: "200"
543-
end
544-
545522
expect {
546523
click_button "Save changes"
547524

@@ -550,13 +527,12 @@
550527
variant_a1.reload
551528
}.not_to change { variant_a1.display_name }
552529

553-
# New variant
530+
# New variant: unit, producer and category are pre-filled, so the only
531+
# errors are the too-long Name and SKU.
554532
within row_containing_name("N" * 256) do
555533
expect(page).to have_field "Name", with: "N" * 256
556534
expect(page).to have_field "SKU", with: "n" * 256
557535
expect(page).to have_content "is too long"
558-
expect(page.find('.col-producer')).to have_content('must exist')
559-
expect(page.find('.col-category')).to have_content('must exist')
560536
expect(page).to have_field "Price", with: "10.25" # other updated value is retained
561537
end
562538

0 commit comments

Comments
 (0)