2
0
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:
Oliver
2023-02-02 09:24:16 +11:00
committed by GitHub
parent c6df0dbb2d
commit 4f029d4d81
15 changed files with 770 additions and 585 deletions

View File

@ -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>`;

View File

@ -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}/`;