From 0b168c1d9afc13c5ac468dc4aa98bf30c6d60bbf Mon Sep 17 00:00:00 2001 From: Lukas <76838159+wolflu05@users.noreply.github.com> Date: Thu, 16 Nov 2023 01:58:51 +0100 Subject: [PATCH] CUI dependent/nested fields (#5924) * Added backend changes to support printing options * Pass printing options seperatly via kwargs for easier api refactor later * Implemented printing options in CUI * Fix js linting * Use translations for printing dialog * Support nested fields in CUI * Added docs * Remove plugin and template fields from send printing options * Fix docs * Added tests * Fix tests * Fix options response and added test for it * Fix tests * Bump api version * Update docs * Apply suggestions from code review * Fix api change date * Added dependent field and improved nested object fields on CUI * Fix: cui js style * Fix process field implementation if the 'old' __ syntax is used for nested fields --- InvenTree/InvenTree/metadata.py | 12 + InvenTree/InvenTree/serializers.py | 88 +++++++ InvenTree/label/api.py | 5 +- InvenTree/templates/js/translated/forms.js | 282 ++++++++++++++++++--- InvenTree/templates/js/translated/label.js | 5 + 5 files changed, 356 insertions(+), 36 deletions(-) diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index 2d4fb95024a..a526cf152e4 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -10,6 +10,7 @@ import InvenTree.permissions import users.models from InvenTree.helpers import str2bool +from InvenTree.serializers import DependentField logger = logging.getLogger('inventree') @@ -242,6 +243,10 @@ def get_field_info(self, field): We take the regular DRF metadata and add our own unique flavor """ + # Try to add the child property to the dependent field to be used by the super call + if self.label_lookup[field] == 'dependent field': + field.get_child(raise_exception=True) + # Run super method first field_info = super().get_field_info(field) @@ -275,4 +280,11 @@ def get_field_info(self, field): else: field_info['api_url'] = model.get_api_url() + # Add more metadata about dependent fields + if field_info['type'] == 'dependent field': + field_info['depends_on'] = field.depends_on + return field_info + + +InvenTreeMetadata.label_lookup[DependentField] = "dependent field" diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 1e2ab595154..96956ec4273 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -2,6 +2,7 @@ import os from collections import OrderedDict +from copy import deepcopy from decimal import Decimal from django.conf import settings @@ -94,6 +95,93 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) +class DependentField(serializers.Field): + """A dependent field can be used to dynamically return child fields based on the value of other fields.""" + child = None + + def __init__(self, *args, depends_on, field_serializer, **kwargs): + """A dependent field can be used to dynamically return child fields based on the value of other fields. + + Example: + This example adds two fields. If the client selects integer, an integer field will be shown, but if he + selects char, an char field will be shown. For any other value, nothing will be shown. + + class TestSerializer(serializers.Serializer): + select_type = serializers.ChoiceField(choices=[ + ("integer", "Integer"), + ("char", "Char"), + ]) + my_field = DependentField(depends_on=["select_type"], field_serializer="get_my_field") + + def get_my_field(self, fields): + if fields["select_type"] == "integer": + return serializers.IntegerField() + if fields["select_type"] == "char": + return serializers.CharField() + """ + super().__init__(*args, **kwargs) + + self.depends_on = depends_on + self.field_serializer = field_serializer + + def get_child(self, raise_exception=False): + """This method tries to extract the child based on the provided data in the request by the client.""" + data = deepcopy(self.context["request"].data) + + def visit_parent(node): + """Recursively extract the data for the parent field/serializer in reverse.""" + nonlocal data + + if node.parent: + visit_parent(node.parent) + + # only do for composite fields and stop right before the current field + if hasattr(node, "child") and node is not self and isinstance(data, dict): + data = data.get(node.field_name, None) + visit_parent(self) + + # ensure that data is a dictionary and that a parent exists + if not isinstance(data, dict) or self.parent is None: + return + + # check if the request data contains the dependent fields, otherwise skip getting the child + for f in self.depends_on: + if not data.get(f, None): + return + + # partially validate the data for options requests that set raise_exception while calling .get_child(...) + if raise_exception: + validation_data = {k: v for k, v in data.items() if k in self.depends_on} + serializer = self.parent.__class__(context=self.context, data=validation_data, partial=True) + serializer.is_valid(raise_exception=raise_exception) + + # try to get the field serializer + field_serializer = getattr(self.parent, self.field_serializer) + child = field_serializer(data) + + if not child: + return + + self.child = child + self.child.bind(field_name='', parent=self) + + def to_internal_value(self, data): + """This method tries to convert the data to an internal representation based on the defined to_internal_value method on the child.""" + self.get_child() + if self.child: + return self.child.to_internal_value(data) + + return None + + def to_representation(self, value): + """This method tries to convert the data to representation based on the defined to_representation method on the child.""" + self.get_child() + if self.child: + return self.child.to_representation(value) + + return None + + class InvenTreeModelSerializer(serializers.ModelSerializer): """Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation.""" diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index 0371b2220f1..a76b8019e72 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -159,7 +159,8 @@ def get_serializer(self, *args, **kwargs): # Check the request to determine if the user has selected a label printing plugin plugin = self.get_plugin(self.request) - serializer = plugin.get_printing_options_serializer(self.request) + kwargs.setdefault('context', self.get_serializer_context()) + serializer = plugin.get_printing_options_serializer(self.request, *args, **kwargs) # if no serializer is defined, return an empty serializer if not serializer: @@ -226,7 +227,7 @@ def print(self, request, items_to_print): raise ValidationError('Label has invalid dimensions') # if the plugin returns a serializer, validate the data - if serializer := plugin.get_printing_options_serializer(request, data=request.data): + if serializer := plugin.get_printing_options_serializer(request, data=request.data, context=self.get_serializer_context()): serializer.is_valid(raise_exception=True) # At this point, we offload the label(s) to the selected plugin. diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 69ab8cbd892..652c744e19c 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -298,7 +298,7 @@ function constructDeleteForm(fields, options) { * - closeText: Text for the "close" button * - fields: list of fields to display, with the following options * - filters: API query filters - * - onEdit: callback when field is edited + * - onEdit: callback or array of callbacks which get fired when field is edited * - secondary: Define a secondary modal form for this field * - label: Specify custom label * - help_text: Specify custom help_text @@ -493,6 +493,32 @@ function constructFormBody(fields, options) { html += options.header_html; } + // process every field by recursively walking down nested fields + const processField = (name, field, optionsField) => { + if (typeof optionsField !== "object") return; + + if (field.type === "nested object" && optionsField.children) { + for (const [k, v] of Object.entries(field.children)) { + processField(`${name}__${k}`, v, optionsField.children[k]); + } + } + + if (field.type === "dependent field") { + if(field.child) { + // copy child attribute from parameters to options + optionsField.child = field.child; + + processField(name, field.child, optionsField.child); + } else { + delete optionsField.child; + } + } + } + + for (const [k,v] of Object.entries(fields)) { + processField(k, v, options.fields[k]); + } + // Client must provide set of fields to be displayed, // otherwise *all* fields will be displayed var displayed_fields = options.fields || fields; @@ -599,14 +625,6 @@ function constructFormBody(fields, options) { var field = fields[field_name]; - switch (field.type) { - // Skip field types which are simply not supported - case 'nested object': - continue; - default: - break; - } - html += constructField(field_name, field, options); } @@ -810,7 +828,7 @@ function insertSecondaryButtons(options) { /* * Extract all specified form values as a single object */ -function extractFormData(fields, options) { +function extractFormData(fields, options, includeLocal = true) { var data = {}; @@ -823,6 +841,7 @@ function extractFormData(fields, options) { if (!field) continue; if (field.type == 'candy') continue; + if (!includeLocal && field.localOnly) continue; data[name] = getFormFieldValue(name, field, options); } @@ -1031,6 +1050,17 @@ function updateFieldValue(name, value, field, options) { } // TODO - Specify an actual value! break; + case 'nested object': + for (const [k, v] of Object.entries(value)) { + if (!(k in field.children)) continue; + updateFieldValue(`${name}__${k}`, v, field.children[k], options); + } + break; + case 'dependent field': + if (field.child) { + updateFieldValue(name, value, field.child, options); + } + break; case 'file upload': case 'image upload': break; @@ -1165,6 +1195,17 @@ function getFormFieldValue(name, field={}, options={}) { case 'email': value = sanitizeInputString(el.val()); break; + case 'nested object': + value = {}; + for (const [name, subField] of Object.entries(field.children)) { + value[name] = getFormFieldValue(subField.name, subField, options); + } + break; + case 'dependent field': + if(!field.child) return undefined; + + value = getFormFieldValue(name, field.child, options); + break; default: value = el.val(); break; @@ -1449,19 +1490,28 @@ function handleFormErrors(errors, fields={}, options={}) { var field = fields[field_name] || {}; var field_errors = errors[field_name]; - if ((field.type == 'nested object') && ('children' in field)) { + // for nested objects with children and dependent fields with a child defined, extract nested errors + if (((field.type == 'nested object') && ('children' in field)) || ((field.type == 'dependent field') && ('child' in field))) { // Handle multi-level nested errors + const handleNestedError = (parent_name, sub_field_errors) => { + for (const sub_field in sub_field_errors) { + const sub_sub_field_name = `${parent_name}__${sub_field}`; + const sub_sub_field_errors = sub_field_errors[sub_field]; - 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_sub_field_errors && isFieldVisible(sub_sub_field_name, options)) { + first_error_field = sub_sub_field_name; + } - if (!first_error_field && sub_field_errors && isFieldVisible(sub_field_name, options)) { - first_error_field = sub_field_name; + // if the error is an object, its a nested object, recursively handle the errors + if (typeof sub_sub_field_errors === "object" && !Array.isArray(sub_sub_field_errors)) { + handleNestedError(sub_sub_field_name, sub_sub_field_errors) + } else { + addFieldErrorMessage(sub_sub_field_name, sub_sub_field_errors, options); + } } - - addFieldErrorMessage(sub_field_name, sub_field_errors, options); } + + handleNestedError(field_name, field_errors); } else if ((field.type == 'field') && ('child' in field)) { // This is a "nested" array field handleNestedArrayErrors(errors, field_name, options); @@ -1556,7 +1606,7 @@ function addFieldCallbacks(fields, options) { var field = fields[name]; - if (!field || !field.onEdit) continue; + if (!field || field.type === "candy") continue; addFieldCallback(name, field, options); } @@ -1564,15 +1614,34 @@ function addFieldCallbacks(fields, options) { function addFieldCallback(name, field, options) { + const el = getFormFieldElement(name, options); - var el = getFormFieldElement(name, options); + if (field.onEdit) { + el.change(function() { - el.change(function() { + var value = getFormFieldValue(name, field, options); + let onEditHandlers = field.onEdit; - var value = getFormFieldValue(name, field, options); + if (!Array.isArray(onEditHandlers)) { + onEditHandlers = [onEditHandlers]; + } - field.onEdit(value, name, field, options); - }); + for (const onEdit of onEditHandlers) { + onEdit(value, name, field, options); + } + }); + } + + // attach field callback for nested fields + if(field.type === "nested object") { + for (const [c_name, c_field] of Object.entries(field.children)) { + addFieldCallback(`${name}__${c_name}`, c_field, options); + } + } + + if(field.type === "dependent field" && field.child) { + addFieldCallback(name, field.child, options); + } } @@ -1727,16 +1796,32 @@ function initializeRelatedFields(fields, options={}) { if (!field || field.hidden) continue; - switch (field.type) { - case 'related field': - initializeRelatedField(field, fields, options); - break; - case 'choice': - initializeChoiceField(field, fields, options); - break; - default: - break; + initializeRelatedFieldsRecursively(field, fields, options); + } +} + +function initializeRelatedFieldsRecursively(field, fields, options) { + switch (field.type) { + case 'related field': + initializeRelatedField(field, fields, options); + break; + case 'choice': + initializeChoiceField(field, fields, options); + break; + case 'nested object': + for (const [c_name, c_field] of Object.entries(field.children)) { + if(!c_field.name) c_field.name = `${field.name}__${c_name}`; + initializeRelatedFieldsRecursively(c_field, field.children, options); + } + break; + case 'dependent field': + if (field.child) { + if(!field.child.name) field.child.name = field.name; + initializeRelatedFieldsRecursively(field.child, fields, options); } + break; + default: + break; } } @@ -2346,7 +2431,7 @@ function constructField(name, parameters, options={}) { html += `
`; // Add a label - if (!options.hideLabels) { + if (!options.hideLabels && parameters.type !== "nested object" && parameters.type !== "dependent field") { html += constructLabel(name, parameters); } @@ -2501,6 +2586,12 @@ function constructInput(name, parameters, options={}) { case 'raw': func = constructRawInput; break; + case 'nested object': + func = constructNestedObject; + break; + case 'dependent field': + func = constructDependentField; + break; default: // Unsupported field type! break; @@ -2780,6 +2871,129 @@ function constructRawInput(name, parameters) { } +/* + * Construct a nested object input + */ +function constructNestedObject(name, parameters, options) { + let html = ` +
+
+
+
${parameters.label}
+
+
+
+ `; + + parameters.field_names = []; + + for (const [key, field] of Object.entries(parameters.children)) { + const subFieldName = `${name}__${key}`; + field.name = subFieldName; + parameters.field_names.push(subFieldName); + + html += constructField(subFieldName, field, options); + } + + html += "
"; + + return html; +} + +function getFieldByNestedPath(name, fields) { + if (typeof name === "string") { + name = name.split("__"); + } + + if (name.length === 0) return fields; + + if (fields.type === "nested object") fields = fields.children; + + if (!(name[0] in fields)) return null; + let field = fields[name[0]]; + + if (field.type === "dependent field" && field.child) { + field = field.child; + } + + return getFieldByNestedPath(name.slice(1), field); +} + +/* + * Construct a dependent field input + */ +function constructDependentField(name, parameters, options) { + // add onEdit handler to all fields this dependent field depends on + for (let d_field_name of parameters.depends_on) { + const d_field = getFieldByNestedPath([...name.split("__").slice(0, -1), d_field_name], options.fields); + if (!d_field) continue; + + const onEdit = (value, name, field, options) => { + if(value === undefined) return; + + // extract the current form data to include in OPTIONS request + const data = extractFormData(options.fields, options, false) + + $.ajax({ + url: options.url, + type: "OPTIONS", + data: JSON.stringify(data), + contentType: "application/json", + dataType: "json", + accepts: { json: "application/json" }, + success: (res) => { + const fields = res.actions[options.method]; + + // merge already entered values in the newly constructed form + options.data = extractFormData(options.fields, options); + + // remove old submit handlers + $(options.modal).off('click', '#modal-form-submit'); + + if (options.method === "POST") { + constructCreateForm(fields, options); + } + + if (options.method === "PUT" || options.method === "PATCH") { + constructChangeForm(fields, options); + } + + if (options.method === "DELETE") { + constructDeleteForm(fields, options); + } + }, + error: (xhr) => showApiError(xhr, options.url) + }); + } + + // attach on edit handler + const originalOnEdit = d_field.onEdit; + d_field.onEdit = [onEdit]; + + if(typeof originalOnEdit === "function") { + d_field.onEdit.push(originalOnEdit); + } else if (Array.isArray(originalOnEdit)) { + // push old onEdit handlers, but omit the old + d_field.onEdit.push(...originalOnEdit.filter(h => h !== d_field._currentDependentFieldOnEdit)); + } + + // track current onEdit handler function + d_field._currentDependentFieldOnEdit = onEdit; + } + + // child is not specified already, return a dummy div with id so no errors can happen + if (!parameters.child) { + return ``; + } + + // copy label to child if not already provided + if(!parameters.child.label) { + parameters.child.label = parameters.label; + } + + // construct the provided child field + return constructField(name, parameters.child, options); +} /* * Construct a 'help text' div based on the field parameters diff --git a/InvenTree/templates/js/translated/label.js b/InvenTree/templates/js/translated/label.js index 57ee46e9196..a9a75f0f569 100644 --- a/InvenTree/templates/js/translated/label.js +++ b/InvenTree/templates/js/translated/label.js @@ -137,6 +137,11 @@ function printLabels(options) { // update form updateForm(formOptions); + + // workaround to fix a bug where one cannot scroll after changing the plugin + // without opening and closing the select box again manually + $("#id__plugin").select2("open"); + $("#id__plugin").select2("close"); } const printingFormOptions = {