diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js
index 898708962a9..e58652dab3e 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -777,62 +777,135 @@ $.extend(erpnext.item, {
function make_fields_from_attribute_values(attr_dict) {
let fields = [];
- let att_key = frm.doc.attributes.map((idx) => idx.attribute);
- att_key.forEach((name, i) => {
+ let attributes = frm.doc.attributes.filter((row) => !row.disabled);
+ attributes.forEach((row, i) => {
+ let name = row.attribute;
if (i % 3 === 0) {
fields.push({ fieldtype: "Section Break" });
}
- fields.push({ fieldtype: "Column Break", label: name });
+ fields.push({ fieldtype: "Column Break" });
fields.push({
- fieldtype: "Data",
- placeholder: "Search",
- fieldname: `search_${frappe.scrub(name)}`,
- onchange: function (e) {
- let value = e.target.value;
- let result = attr_dict[name].filter((attr_value) =>
- attr_value.toString().toLowerCase().includes(value.toLowerCase())
- );
- attr_dict[name].forEach((attr_value) => {
- if (result.includes(attr_value)) {
- me.multiple_variant_dialog.set_df_property(attr_value, "hidden", 0);
- } else {
- me.multiple_variant_dialog.set_df_property(attr_value, "hidden", 1);
- }
- });
- },
- });
- attr_dict[name].forEach((value) => {
- fields.push({
- fieldtype: "Check",
- label: value,
- fieldname: value,
- default: 0,
- onchange: function () {
- let selected_attributes = get_selected_attributes();
- let lengths = Object.keys(selected_attributes).map((key) => {
- return selected_attributes[key].length;
- });
- if (!lengths.length) {
- me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants"));
- me.multiple_variant_dialog.disable_primary_action();
- } else {
- let no_of_combinations = lengths.reduce((a, b) => a * b, 1);
- let msg;
- if (no_of_combinations === 1) {
- msg = __("Make {0} Variant", [no_of_combinations]);
- } else {
- msg = __("Make {0} Variants", [no_of_combinations]);
- }
- me.multiple_variant_dialog.get_primary_btn().html(msg);
- me.multiple_variant_dialog.enable_primary_action();
- }
- },
- });
+ fieldtype: "MultiSelectPills",
+ label: name,
+ fieldname: frappe.scrub(name),
+ placeholder: __("Search values..."),
+ get_data: (txt) => get_attribute_suggestions(attr_dict[name], txt),
+ onchange: update_primary_action,
});
});
return fields;
}
+ function get_attribute_suggestions(spec, txt) {
+ if (!spec) return [];
+ return Array.isArray(spec) ? filter_list(spec, txt) : numeric_suggestions(spec, txt);
+ }
+
+ // Cap matches so a long value list never hands everything to Awesomplete,
+ // which would freeze the browser.
+ function filter_list(values, txt) {
+ txt = (txt || "").toLowerCase();
+ let matches = [];
+ for (let value of values) {
+ if (!txt || value.toLowerCase().includes(txt)) {
+ matches.push(value);
+ if (matches.length >= 50) break;
+ }
+ }
+ return matches;
+ }
+
+ // Numeric ranges aren't enumerated. With no input, preview the first few
+ // values; once the user types, accept it only if it lies on the increment
+ // within [from, to]. Both paths are cheap even for huge ranges.
+ function numeric_suggestions(range, txt) {
+ let { from_range: from, to_range: to, increment } = range;
+ if (!(increment > 0) || from > to) return [];
+
+ txt = (txt || "").trim();
+ if (!txt) {
+ let preview = [];
+ for (
+ let value = from;
+ value <= to && preview.length < 50;
+ value = flt(value + increment, 6)
+ ) {
+ preview.push(String(value));
+ }
+ return preview;
+ }
+
+ return is_valid_attribute_value(range, txt) ? [String(flt(txt, 6))] : [];
+ }
+
+ function is_valid_attribute_value(spec, value) {
+ if (!spec || !value) return false;
+ if (Array.isArray(spec)) return spec.includes(value);
+
+ let { from_range: from, to_range: to, increment } = spec;
+ if (!(increment > 0)) return false;
+
+ // Reject anything that isn't cleanly a number ("abc", "5000xyz", "");
+ // flt would coerce these to 0 and wrongly accept them.
+ let text = String(value).trim();
+ let num = Number(text);
+ if (text === "" || !Number.isFinite(num)) return false;
+
+ if (num < from || num > to) return false;
+ let steps = (num - from) / increment;
+ return Math.abs(Math.round(steps) - steps) <= 1e-6;
+ }
+
+ // Block variant creation if ANY value is invalid. Checks both the committed
+ // pills and any text still sitting in the input box (typed but not selected),
+ // so garbage like "00A" can never slip through to creation.
+ function validate_selected_attributes() {
+ let invalid = [];
+ frm.doc.attributes.forEach((row) => {
+ if (row.disabled) return;
+ let field = me.multiple_variant_dialog.get_field(frappe.scrub(row.attribute));
+ if (!field) return;
+
+ let spec = attr_val_fields[row.attribute];
+ let values = (field.get_value() || []).slice();
+ let pending = (field.$input?.val() || "").trim();
+ if (pending) values.push(pending);
+
+ let bad = [...new Set(values.filter((v) => !is_valid_attribute_value(spec, v)))];
+ if (bad.length) {
+ invalid.push(`${frappe.utils.escape_html(row.attribute)}: ${bad.join(", ")}`);
+ }
+ });
+
+ if (invalid.length) {
+ frappe.throw({
+ title: __("Invalid Attribute Values"),
+ message:
+ __("Please remove the following invalid values before creating variants:") +
+ "
" +
+ invalid.join("
"),
+ indicator: "red",
+ });
+ }
+ }
+
+ function update_primary_action() {
+ let selected_attributes = get_selected_attributes();
+ let counts = Object.keys(selected_attributes).map((key) => selected_attributes[key].length);
+ if (!counts.length) {
+ me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants"));
+ me.multiple_variant_dialog.disable_primary_action();
+ } else {
+ let no_of_combinations = counts.reduce((a, b) => a * b, 1);
+ let msg =
+ no_of_combinations === 1
+ ? __("Make {0} Variant", [no_of_combinations])
+ : __("Make {0} Variants", [no_of_combinations]);
+ me.multiple_variant_dialog.get_primary_btn().html(msg);
+ me.multiple_variant_dialog.enable_primary_action();
+ }
+ }
+
function make_and_show_dialog(fields) {
me.multiple_variant_dialog = new frappe.ui.Dialog({
title: __("Select Attribute Values"),
@@ -858,6 +931,8 @@ $.extend(erpnext.item, {
});
me.multiple_variant_dialog.set_primary_action(__("Create Variants"), () => {
+ validate_selected_attributes();
+
let selected_attributes = get_selected_attributes();
let use_template_image = me.multiple_variant_dialog.get_value("use_template_image");
@@ -885,72 +960,70 @@ $.extend(erpnext.item, {
});
});
- $($(me.multiple_variant_dialog.$wrapper.find(".form-column")).find(".frappe-control")).css(
- "margin-bottom",
- "0px"
- );
-
me.multiple_variant_dialog.disable_primary_action();
me.multiple_variant_dialog.clear();
me.multiple_variant_dialog.show();
- me.multiple_variant_dialog.$wrapper
- .find("div[data-fieldname^='search_']")
- .find(".clearfix")
- .hide();
}
function get_selected_attributes() {
let selected_attributes = {};
- me.multiple_variant_dialog.$wrapper.find(".form-column").each((i, col) => {
- if (i === 0) return;
- let attribute_name = $(col).find(".column-label").html().trim();
- selected_attributes[attribute_name] = [];
- let checked_opts = $(col).find(".checkbox input");
- checked_opts.each((i, opt) => {
- if ($(opt).is(":checked")) {
- selected_attributes[attribute_name].push($(opt).attr("data-fieldname"));
- }
- });
- if (!selected_attributes[attribute_name].length) {
- delete selected_attributes[attribute_name];
+ frm.doc.attributes.forEach((row) => {
+ if (row.disabled) return;
+ let values = me.multiple_variant_dialog.get_value(frappe.scrub(row.attribute));
+ if (values && values.length) {
+ selected_attributes[row.attribute] = values;
}
});
-
return selected_attributes;
}
frm.doc.attributes.forEach(function (d) {
if (!d.disabled) {
let p = new Promise((resolve) => {
- if (!d.numeric_values) {
- frappe
- .call({
- method: "frappe.client.get_list",
- args: {
- doctype: "Item Attribute Value",
- filters: [["parent", "=", d.attribute]],
- fields: ["attribute_value"],
- limit_page_length: 0,
- parent: "Item Attribute",
- order_by: "idx",
- },
- })
- .then((r) => {
- if (r.message) {
- attr_val_fields[d.attribute] = r.message.map(function (d) {
- return d.attribute_value;
+ // Read the numeric configuration from the Item Attribute master
+ // instead of the variant attribute row, which may be stale or
+ // blank if the attribute was made numeric after it was added here.
+ frappe.db
+ .get_value("Item Attribute", d.attribute, [
+ "numeric_values",
+ "from_range",
+ "to_range",
+ "increment",
+ ])
+ .then((res) => {
+ let attr = res.message || {};
+
+ if (!attr.numeric_values) {
+ frappe
+ .call({
+ method: "frappe.client.get_list",
+ args: {
+ doctype: "Item Attribute Value",
+ filters: [["parent", "=", d.attribute]],
+ fields: ["attribute_value"],
+ limit_page_length: 0,
+ parent: "Item Attribute",
+ order_by: "idx",
+ },
+ })
+ .then((r) => {
+ attr_val_fields[d.attribute] = (r.message || []).map(
+ (row) => row.attribute_value
+ );
+ resolve();
});
- resolve();
- }
- });
- } else {
- let values = [];
- for (var i = d.from_range; i <= d.to_range; i = flt(i + d.increment, 6)) {
- values.push(i);
- }
- attr_val_fields[d.attribute] = values;
- resolve();
- }
+ } else {
+ // Store the range instead of enumerating it; a large range
+ // (e.g. 1-100000) is slow to build and to search. Values are
+ // validated against the range on demand while typing.
+ attr_val_fields[d.attribute] = {
+ from_range: flt(attr.from_range),
+ to_range: flt(attr.to_range),
+ increment: flt(attr.increment),
+ };
+ resolve();
+ }
+ });
});
promises.push(p);