mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10:54 +00:00
Improvements for part creation API endpoint (#4281)
* Refactor javascript for creating a new part * Simplify method of removing create fields from serializer * Fix bug which resulted in multiple model instances being created * remove custom code required on Part model * Reorganize existing Part API test code * Add child serializer for part duplication options * Part duplication is now handled by the DRF serializer - Improved validation options - API is self-documenting (no more secret fields) - More DRY * Initial stock is now handled by the DRF serializer * Adds child serializer for adding initial supplier data for a Part instance * Create initial supplier and manufacturer parts as specified * Adding unit tests * Add unit tests for part duplication via API * Bump API version * Add javascript for automatically extracting info for nested fields * Improvements for part creation form rendering - Move to nested fields (using API metadata) - Visual improvements - Improve some field name / description values * Properly format nested fields for sending to the server * Handle error case for scrollIntoView * Display errors for nested fields * Fix bug for filling part category * JS linting fixes * Unit test fixes * Fixes for unit tests * Further fixes to unit tests
This commit is contained in:
@ -429,6 +429,52 @@ function constructForm(url, options) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Extracted information about a 'nested field' from the API metadata:
|
||||
*
|
||||
* - Nested fields are designated using a '__' (double underscore) separator
|
||||
* - Currently only single-depth nesting is supported
|
||||
*/
|
||||
function extractNestedField(field_name, fields) {
|
||||
|
||||
var field_path = field_name.split('__');
|
||||
var parent_name = field_path[0];
|
||||
var child_name = field_path[1];
|
||||
|
||||
var parent_field = fields[parent_name];
|
||||
var child_field = null;
|
||||
|
||||
// Check that the parent field exists
|
||||
if (!parent_field) {
|
||||
console.warn(`Expected parent field '${parent_name}' missing from API metadata`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that the parent field is a 'nested object'
|
||||
if (parent_field.type != 'nested object') {
|
||||
console.warn(`Parent field '${parent_name}' is not designated as a nested object`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that the field has a 'children' attribute
|
||||
if ('children' in parent_field) {
|
||||
child_field = parent_field['children'][child_name];
|
||||
} else {
|
||||
console.warn(`Parent field '${parent_name}' missing 'children' field`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (child_field) {
|
||||
// Mark this as a nested child field
|
||||
child_field['nested_child'] = true;
|
||||
child_field['parent_name'] = parent_name;
|
||||
child_field['child_name'] = child_name;
|
||||
}
|
||||
|
||||
return child_field;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct a modal form based on the provided options
|
||||
*
|
||||
@ -476,10 +522,22 @@ function constructFormBody(fields, options) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Provide each field object with its own name
|
||||
for (field in fields) {
|
||||
fields[field].name = field;
|
||||
|
||||
/* Handle metadata for 'nested' fields.
|
||||
* - Nested fields are designated using a '__' (double underscore) separator
|
||||
* - Currently only single depth nesting is supported
|
||||
*/
|
||||
if (field.includes('__')) {
|
||||
var nested_field_info = extractNestedField(field, fields);
|
||||
|
||||
// Update the field data
|
||||
fields[field] = Object.assign(fields[field], nested_field_info);
|
||||
}
|
||||
|
||||
// If any "instance_filters" are defined for the endpoint, copy them across (overwrite)
|
||||
if (fields[field].instance_filters) {
|
||||
fields[field].filters = Object.assign(fields[field].filters || {}, fields[field].instance_filters);
|
||||
@ -802,7 +860,16 @@ function submitFormData(fields, options) {
|
||||
// Normal field (not a file or image)
|
||||
form_data.append(name, value);
|
||||
|
||||
data[name] = value;
|
||||
if (field.parent_name && field.child_name) {
|
||||
// "Nested" fields are handled a little differently
|
||||
if (!(field.parent_name in data)) {
|
||||
data[field.parent_name] = {};
|
||||
}
|
||||
|
||||
data[field.parent_name][field.child_name] = value;
|
||||
} else {
|
||||
data[name] = value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`Could not find field matching '${name}'`);
|
||||
@ -1171,7 +1238,7 @@ function clearFormErrors(options={}) {
|
||||
*
|
||||
*/
|
||||
|
||||
function handleNestedErrors(errors, field_name, options={}) {
|
||||
function handleNestedArrayErrors(errors, field_name, options={}) {
|
||||
|
||||
var error_list = errors[field_name];
|
||||
|
||||
@ -1184,7 +1251,7 @@ function handleNestedErrors(errors, field_name, options={}) {
|
||||
|
||||
// Nest list must be provided!
|
||||
if (!nest_list) {
|
||||
console.warn(`handleNestedErrors missing nesting options for field '${fieldName}'`);
|
||||
console.warn(`handleNestedArrayErrors missing nesting options for field '${fieldName}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1193,7 +1260,7 @@ function handleNestedErrors(errors, field_name, options={}) {
|
||||
var error_item = error_list[idx];
|
||||
|
||||
if (idx >= nest_list.length) {
|
||||
console.warn(`handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
|
||||
console.warn(`handleNestedArrayErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -1294,16 +1361,27 @@ function handleFormErrors(errors, fields={}, options={}) {
|
||||
for (var field_name in errors) {
|
||||
|
||||
var field = fields[field_name] || {};
|
||||
var field_errors = errors[field_name];
|
||||
|
||||
if ((field.type == 'field') && ('child' in field)) {
|
||||
// This is a "nested" field
|
||||
handleNestedErrors(errors, field_name, options);
|
||||
if ((field.type == 'nested object') && ('children' in field)) {
|
||||
// Handle multi-level nested errors
|
||||
|
||||
for (var sub_field in field_errors) {
|
||||
var sub_field_name = `${field_name}__${sub_field}`;
|
||||
var sub_field_errors = field_errors[sub_field];
|
||||
|
||||
if (!first_error_field && sub_field_errors && isFieldVisible(sub_field_name, options)) {
|
||||
first_error_field = sub_field_name;
|
||||
}
|
||||
|
||||
addFieldErrorMessage(sub_field_name, sub_field_errors, options);
|
||||
}
|
||||
} else if ((field.type == 'field') && ('child' in field)) {
|
||||
// This is a "nested" array field
|
||||
handleNestedArrayErrors(errors, field_name, options);
|
||||
} else {
|
||||
// This is a "simple" field
|
||||
|
||||
var field_errors = errors[field_name];
|
||||
|
||||
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
|
||||
if (!first_error_field && field_errors && isFieldVisible(field_name, options)) {
|
||||
first_error_field = field_name;
|
||||
}
|
||||
|
||||
@ -1313,9 +1391,15 @@ function handleFormErrors(errors, fields={}, options={}) {
|
||||
|
||||
if (first_error_field) {
|
||||
// Ensure that the field in question is visible
|
||||
document.querySelector(`#div_id_${field_name}`).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
var error_element = document.querySelector(`#div_id_${first_error_field}`);
|
||||
|
||||
if (error_element) {
|
||||
error_element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} else {
|
||||
console.warn(`Could not scroll to field '${first_error_field}' - element not found`);
|
||||
}
|
||||
} else {
|
||||
// Scroll to the top of the form
|
||||
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
|
||||
@ -2058,8 +2142,10 @@ function constructField(name, parameters, options={}) {
|
||||
return constructHiddenInput(field_name, parameters, options);
|
||||
}
|
||||
|
||||
var group_name = parameters.group || parameters.parent_name;
|
||||
|
||||
// Are we ending a group?
|
||||
if (options.current_group && parameters.group != options.current_group) {
|
||||
if (options.current_group && group_name != options.current_group) {
|
||||
html += `</div></div>`;
|
||||
|
||||
// Null out the current "group" so we can start a new one
|
||||
@ -2067,9 +2153,9 @@ function constructField(name, parameters, options={}) {
|
||||
}
|
||||
|
||||
// Are we starting a new group?
|
||||
if (parameters.group) {
|
||||
if (group_name) {
|
||||
|
||||
var group = parameters.group;
|
||||
var group = group_name;
|
||||
|
||||
var group_id = getFieldName(group, options);
|
||||
|
||||
@ -2077,7 +2163,7 @@ function constructField(name, parameters, options={}) {
|
||||
|
||||
// Are we starting a new group?
|
||||
// Add HTML for the start of a separate panel
|
||||
if (parameters.group != options.current_group) {
|
||||
if (group_name != options.current_group) {
|
||||
|
||||
html += `
|
||||
<div class='panel form-panel' id='form-panel-${group_id}' group='${group}'>
|
||||
@ -2091,7 +2177,7 @@ function constructField(name, parameters, options={}) {
|
||||
html += `<div>`;
|
||||
}
|
||||
|
||||
html += `<h4 style='display: inline;'>${group_options.title || group}</h4>`;
|
||||
html += `<h5 style='display: inline;'>${group_options.title || group}</h5>`;
|
||||
|
||||
if (group_options.collapsible) {
|
||||
html += `</a>`;
|
||||
|
@ -20,6 +20,7 @@
|
||||
*/
|
||||
|
||||
/* exported
|
||||
createPart,
|
||||
deletePart,
|
||||
deletePartCategory,
|
||||
duplicateBom,
|
||||
@ -63,11 +64,16 @@ function partGroups() {
|
||||
title: '{% trans "Part Duplication Options" %}',
|
||||
collapsible: true,
|
||||
},
|
||||
supplier: {
|
||||
title: '{% trans "Supplier Options" %}',
|
||||
initial_stock: {
|
||||
title: '{% trans "Initial Stock" %}',
|
||||
collapsible: true,
|
||||
hidden: !global_settings.PART_PURCHASEABLE,
|
||||
}
|
||||
hidden: !global_settings.PART_CREATE_INITIAL,
|
||||
},
|
||||
initial_supplier: {
|
||||
title: '{% trans "Initial Supplier Data" %}',
|
||||
collapsible: true,
|
||||
hidden: !global_settings.PART_CREATE_SUPPLIER,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -87,7 +93,7 @@ function partFields(options={}) {
|
||||
},
|
||||
filters: {
|
||||
structural: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
name: {},
|
||||
IPN: {},
|
||||
@ -151,6 +157,10 @@ function partFields(options={}) {
|
||||
},
|
||||
};
|
||||
|
||||
if (options.category) {
|
||||
fields.category.value = options.category;
|
||||
}
|
||||
|
||||
// If editing a part, we can set the "active" status
|
||||
if (options.edit) {
|
||||
fields.active = {
|
||||
@ -164,38 +174,33 @@ function partFields(options={}) {
|
||||
}
|
||||
|
||||
if (options.create || options.duplicate) {
|
||||
|
||||
// Add fields for creating initial supplier data
|
||||
|
||||
// Add fields for creating initial stock
|
||||
if (global_settings.PART_CREATE_INITIAL) {
|
||||
|
||||
fields.initial_stock = {
|
||||
type: 'boolean',
|
||||
label: '{% trans "Create Initial Stock" %}',
|
||||
help_text: '{% trans "Create an initial stock item for this part" %}',
|
||||
group: 'create',
|
||||
};
|
||||
|
||||
fields.initial_stock_quantity = {
|
||||
type: 'decimal',
|
||||
value: 1,
|
||||
label: '{% trans "Initial Stock Quantity" %}',
|
||||
help_text: '{% trans "Specify initial stock quantity for this part" %}',
|
||||
group: 'create',
|
||||
};
|
||||
|
||||
// TODO - Allow initial location of stock to be specified
|
||||
fields.initial_stock_location = {
|
||||
label: '{% trans "Location" %}',
|
||||
help_text: '{% trans "Select destination stock location" %}',
|
||||
type: 'related field',
|
||||
required: true,
|
||||
api_url: `/api/stock/location/`,
|
||||
model: 'stocklocation',
|
||||
group: 'create',
|
||||
fields.initial_stock__quantity = {
|
||||
value: 0,
|
||||
};
|
||||
fields.initial_stock__location = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Additional fields when "creating" a new part
|
||||
if (options.create) {
|
||||
// Add fields for creating initial supplier data
|
||||
if (global_settings.PART_CREATE_SUPPLIER) {
|
||||
fields.initial_supplier__supplier = {
|
||||
filters: {
|
||||
is_supplier: true,
|
||||
}
|
||||
};
|
||||
fields.initial_supplier__sku = {};
|
||||
fields.initial_supplier__manufacturer = {
|
||||
filters: {
|
||||
is_manufacturer: true,
|
||||
}
|
||||
};
|
||||
fields.initial_supplier__mpn = {};
|
||||
}
|
||||
|
||||
// No supplier parts available yet
|
||||
delete fields['default_supplier'];
|
||||
@ -207,87 +212,28 @@ function partFields(options={}) {
|
||||
value: global_settings.PART_CATEGORY_PARAMETERS,
|
||||
group: 'create',
|
||||
};
|
||||
|
||||
// Supplier options
|
||||
fields.add_supplier_info = {
|
||||
type: 'boolean',
|
||||
label: '{% trans "Add Supplier Data" %}',
|
||||
help_text: '{% trans "Create initial supplier data for this part" %}',
|
||||
group: 'supplier',
|
||||
};
|
||||
|
||||
fields.supplier = {
|
||||
type: 'related field',
|
||||
model: 'company',
|
||||
label: '{% trans "Supplier" %}',
|
||||
help_text: '{% trans "Select supplier" %}',
|
||||
filters: {
|
||||
'is_supplier': true,
|
||||
},
|
||||
api_url: '{% url "api-company-list" %}',
|
||||
group: 'supplier',
|
||||
};
|
||||
|
||||
fields.SKU = {
|
||||
type: 'string',
|
||||
label: '{% trans "SKU" %}',
|
||||
help_text: '{% trans "Supplier stock keeping unit" %}',
|
||||
group: 'supplier',
|
||||
};
|
||||
|
||||
fields.manufacturer = {
|
||||
type: 'related field',
|
||||
model: 'company',
|
||||
label: '{% trans "Manufacturer" %}',
|
||||
help_text: '{% trans "Select manufacturer" %}',
|
||||
filters: {
|
||||
'is_manufacturer': true,
|
||||
},
|
||||
api_url: '{% url "api-company-list" %}',
|
||||
group: 'supplier',
|
||||
};
|
||||
|
||||
fields.MPN = {
|
||||
type: 'string',
|
||||
label: '{% trans "MPN" %}',
|
||||
help_text: '{% trans "Manufacturer Part Number" %}',
|
||||
group: 'supplier',
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// Additional fields when "duplicating" a part
|
||||
if (options.duplicate) {
|
||||
|
||||
fields.copy_from = {
|
||||
type: 'integer',
|
||||
hidden: true,
|
||||
// The following fields exist under the child serializer named 'duplicate'
|
||||
|
||||
fields.duplicate__part = {
|
||||
value: options.duplicate,
|
||||
group: 'duplicate',
|
||||
},
|
||||
|
||||
fields.copy_image = {
|
||||
type: 'boolean',
|
||||
label: '{% trans "Copy Image" %}',
|
||||
help_text: '{% trans "Copy image from original part" %}',
|
||||
value: true,
|
||||
group: 'duplicate',
|
||||
},
|
||||
|
||||
fields.copy_bom = {
|
||||
type: 'boolean',
|
||||
label: '{% trans "Copy BOM" %}',
|
||||
help_text: '{% trans "Copy bill of materials from original part" %}',
|
||||
value: global_settings.PART_COPY_BOM,
|
||||
group: 'duplicate',
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
fields.copy_parameters = {
|
||||
type: 'boolean',
|
||||
label: '{% trans "Copy Parameters" %}',
|
||||
help_text: '{% trans "Copy parameter data from original part" %}',
|
||||
fields.duplicate__copy_image = {
|
||||
value: true,
|
||||
};
|
||||
|
||||
fields.duplicate__copy_bom = {
|
||||
value: global_settings.PART_COPY_BOM,
|
||||
};
|
||||
|
||||
fields.duplicate__copy_parameters = {
|
||||
value: global_settings.PART_COPY_PARAMETERS,
|
||||
group: 'duplicate',
|
||||
};
|
||||
}
|
||||
|
||||
@ -295,6 +241,9 @@ function partFields(options={}) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct a set of fields for a PartCategory intance
|
||||
*/
|
||||
function categoryFields() {
|
||||
return {
|
||||
parent: {
|
||||
@ -378,6 +327,32 @@ function deletePartCategory(pk, options={}) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Launches a form to create a new Part instance
|
||||
*/
|
||||
function createPart(options={}) {
|
||||
|
||||
options.create = true;
|
||||
|
||||
constructForm('{% url "api-part-list" %}', {
|
||||
method: 'POST',
|
||||
fields: partFields(options),
|
||||
groups: partGroups(),
|
||||
title: '{% trans "Create Part" %}',
|
||||
reloadFormAfterSuccess: true,
|
||||
persistMessage: '{% trans "Create another part after this one" %}',
|
||||
successMessage: '{% trans "Part created successfully" %}',
|
||||
onSuccess: function(data) {
|
||||
// Follow the new part
|
||||
location.href = `/part/${data.pk}/`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Launches a form to edit an existing Part instance
|
||||
*/
|
||||
function editPart(pk) {
|
||||
|
||||
var url = `/api/part/${pk}/`;
|
||||
|
Reference in New Issue
Block a user