{% load i18n %} {% load inventree_extras %} /* globals attachToggle, createNewModal, inventreeFormDataUpload, inventreeGet, inventreePut, modalEnable, modalShowSubmitButton, renderBuild, renderCompany, renderManufacturerPart, renderOwner, renderPart, renderPartCategory, renderPartParameterTemplate, renderStockItem, renderStockLocation, renderSupplierPart, renderUser, showAlertDialog, showAlertOrCache, showApiError, */ /* exported setFormGroupVisibility */ /** * * This file contains code for rendering (and managing) HTML forms * which are served via the django-drf API. * * The django DRF library provides an OPTIONS method for each API endpoint, * which allows us to introspect the available fields at any given endpoint. * * The OPTIONS method provides the following information for each available field: * * - Field name * - Field label (translated) * - Field help text (translated) * - Field type * - Read / write status * - Field required status * - min_value / max_value * */ /* * Return true if the OPTIONS specify that the user * can perform a GET method at the endpoint. */ function canView(OPTIONS) { if ('actions' in OPTIONS) { return ('GET' in OPTIONS.actions); } else { return false; } } /* * Return true if the OPTIONS specify that the user * can perform a POST method at the endpoint */ function canCreate(OPTIONS) { if ('actions' in OPTIONS) { return ('POST' in OPTIONS.actions); } else { return false; } } /* * Return true if the OPTIONS specify that the user * can perform a PUT or PATCH method at the endpoint */ function canChange(OPTIONS) { if ('actions' in OPTIONS) { return ('PUT' in OPTIONS.actions || 'PATCH' in OPTIONS.actions); } else { return false; } } /* * Return true if the OPTIONS specify that the user * can perform a DELETE method at the endpoint */ function canDelete(OPTIONS) { if ('actions' in OPTIONS) { return ('DELETE' in OPTIONS.actions); } else { return false; } } /* * Get the API endpoint options at the provided URL, * using a HTTP options request. */ function getApiEndpointOptions(url, callback) { // Return the ajax request object $.ajax({ url: url, type: 'OPTIONS', contentType: 'application/json', dataType: 'json', accepts: { json: 'application/json', }, success: callback, error: function() { // TODO: Handle error console.log(`ERROR in getApiEndpointOptions at '${url}'`); } }); } /* * Construct a 'creation' (POST) form, to create a new model in the database. * * arguments: * - fields: The 'actions' object provided by the OPTIONS endpoint * * options: * - */ function constructCreateForm(fields, options) { // Check if default values were provided for any fields for (const name in fields) { var field = fields[name]; var field_options = options.fields[name] || {}; // If a 'value' is not provided for the field, if (field.value == null) { if ('value' in field_options) { // Client has specified the default value for the field field.value = field_options.value; } else if (field.default != null) { // OPTIONS endpoint provided default value for this field field.value = field.default; } } } // We should have enough information to create the form! constructFormBody(fields, options); } /* * Construct a 'change' (PATCH) form, to create a new model in the database. * * arguments: * - fields: The 'actions' object provided by the OPTIONS endpoint * * options: * - */ function constructChangeForm(fields, options) { // Request existing data from the API endpoint $.ajax({ url: options.url, type: 'GET', contentType: 'application/json', dataType: 'json', accepts: { json: 'application/json', }, success: function(data) { // Push existing 'value' to each field for (const field in data) { if (field in fields) { fields[field].value = data[field]; } } // Store the entire data object options.instance = data; constructFormBody(fields, options); }, error: function() { // TODO: Handle error here console.log(`ERROR in constructChangeForm at '${options.url}'`); } }); } /* * Construct a 'delete' form, to remove a model instance from the database. * * arguments: * - fields: The 'actions' object provided by the OPTIONS request * - options: The 'options' object provided by the client */ function constructDeleteForm(fields, options) { // Force the "confirm" property if not set if (!('confirm' in options)) { options.confirm = true; } // Request existing data from the API endpoint // This data can be used to render some information on the form $.ajax({ url: options.url, type: 'GET', contentType: 'application/json', dataType: 'json', accepts: { json: 'application/json', }, success: function(data) { // Store the instance data options.instance = data; constructFormBody(fields, options); }, error: function() { // TODO: Handle error here console.log(`ERROR in constructDeleteForm at '${options.url}`); } }); } /* * Request API OPTIONS data from the server, * and construct a modal form based on the response. * * url: API URL which defines form data * options: * - method: The HTTP method e.g. 'PUT', 'POST', 'DELETE' (default='PATCH') * - title: The form title * - submitText: Text for the "submit" button * - 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 * - secondary: Define a secondary modal form for this field * - label: Specify custom label * - help_text: Specify custom help_text * - placeholder: Specify custom placeholder text * - value: Specify initial value * - hidden: Set to true to hide the field * - icon: font-awesome icon to display before the field * - prefix: Custom HTML prefix to display before the field * - data: map of data to fill out field values with * - focus: Name of field to focus on when modal is displayed * - preventClose: Set to true to prevent form from closing on success * - onSuccess: callback function when form action is successful * - follow: If a 'url' is provided by the API on success, redirect to it * - redirect: A URL to redirect to after form success * - reload: Set to true to reload the current page after form success * - confirm: Set to true to require a "confirm" button * - confirmText: Text for confirm button (default = "Confirm") * */ function constructForm(url, options) { // An "empty" form will be defined locally if (url == null) { constructFormBody({}, options); } options.fields = options.fields || {}; // Save the URL options.url = url; // Default HTTP method options.method = options.method || 'PATCH'; // Default "groups" definition options.groups = options.groups || {}; options.current_group = null; // Construct an "empty" data object if not provided if (!options.data) { options.data = {}; } // Request OPTIONS endpoint from the API getApiEndpointOptions(url, function(OPTIONS) { /* * Determine what "type" of form we want to construct, * based on the requested action. * * First we must determine if the user has the correct permissions! */ switch (options.method) { case 'POST': if (canCreate(OPTIONS)) { constructCreateForm(OPTIONS.actions.POST, options); } else { // User does not have permission to POST to the endpoint showAlertDialog( '{% trans "Action Prohibited" %}', '{% trans "Create operation not allowed" %}' ); console.log(`'POST action unavailable at ${url}`); } break; case 'PUT': case 'PATCH': if (canChange(OPTIONS)) { constructChangeForm(OPTIONS.actions.PUT, options); } else { // User does not have permission to PUT/PATCH to the endpoint showAlertDialog( '{% trans "Action Prohibited" %}', '{% trans "Update operation not allowed" %}' ); console.log(`${options.method} action unavailable at ${url}`); } break; case 'DELETE': if (canDelete(OPTIONS)) { constructDeleteForm(OPTIONS.actions.DELETE, options); } else { // User does not have permission to DELETE to the endpoint showAlertDialog( '{% trans "Action Prohibited" %}', '{% trans "Delete operation not allowed" %}' ); console.log(`DELETE action unavailable at ${url}`); } break; case 'GET': if (canView(OPTIONS)) { // TODO? } else { // User does not have permission to GET to the endpoint showAlertDialog( '{% trans "Action Prohibited" %}', '{% trans "View operation not allowed" %}' ); console.log(`GET action unavailable at ${url}`); } break; default: console.log(`constructForm() called with invalid method '${options.method}'`); break; } }); } /* * Construct a modal form based on the provided options * * arguments: * - fields: The endpoint description returned from the OPTIONS request * - options: form options object provided by the client. */ function constructFormBody(fields, options) { var html = ''; // Client must provide set of fields to be displayed, // otherwise *all* fields will be displayed var displayed_fields = options.fields || fields; // Handle initial data overrides if (options.data) { for (const field in options.data) { if (field in fields) { fields[field].value = options.data[field]; } } } // Initialize an "empty" field for each specified field for (field in displayed_fields) { if (!(field in fields)) { fields[field] = {}; } } // Provide each field object with its own name for (field in fields) { fields[field].name = field; // 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); } var field_options = displayed_fields[field]; // Copy custom options across to the fields object if (field_options) { // Override existing query filters (if provided!) fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters); for (var opt in field_options) { var val = field_options[opt]; if (opt == 'filters') { // ignore filters (see above) } else if (opt == 'icon') { // Specify custom icon fields[field].prefix = ``; } else { fields[field][opt] = field_options[opt]; } } } } // Construct an ordered list of field names var field_names = []; for (var name in displayed_fields) { field_names.push(name); // Field not specified in the API, but the client wishes to add it! if (!(name in fields)) { fields[name] = displayed_fields[name]; } } // Push the ordered field names into the options, // allowing successive functions to access them. options.field_names = field_names; // Render selected fields for (var idx = 0; idx < field_names.length; idx++) { var field_name = field_names[idx]; 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); } if (options.current_group) { // Close out the current group html += ``; } // Create a new modal if one does not exists if (!options.modal) { options.modal = createNewModal(options); } var modal = options.modal; modalEnable(modal, true); // Insert generated form content $(modal).find('#form-content').html(html); if (options.preFormContent) { $(modal).find('#pre-form-content').html(options.preFormContent); } if (options.postFormContent) { $(modal).find('#post-form-content').html(options.postFormContent); } // Clear any existing buttons from the modal $(modal).find('#modal-footer-buttons').html(''); // Insert "confirm" button (if required) if (options.confirm) { insertConfirmButton(options); } // Display the modal $(modal).modal('show'); updateFieldValues(fields, options); // Setup related fields initializeRelatedFields(fields, options); // Attach edit callbacks (if required) addFieldCallbacks(fields, options); // Attach clear callbacks (if required) addClearCallbacks(fields, options); attachToggle(modal); $(modal + ' .select2-container').addClass('select-full-width'); $(modal + ' .select2-container').css('width', '100%'); modalShowSubmitButton(modal, true); $(modal).on('click', '#modal-form-submit', function() { // Immediately disable the "submit" button, // to prevent the form being submitted multiple times! $(options.modal).find('#modal-form-submit').prop('disabled', true); // Run custom code before normal form submission if (options.beforeSubmit) { options.beforeSubmit(fields, options); } // Run custom code instead of normal form submission if (options.onSubmit) { options.onSubmit(fields, options); } else { submitFormData(fields, options); } }); initializeGroups(fields, options); if (options.afterRender) { // Custom callback function after form rendering options.afterRender(fields, options); } // Scroll to the top $(options.modal).find('.modal-form-content-wrapper').scrollTop(0); } // Add a "confirm" checkbox to the modal // The "submit" button will be disabled unless "confirm" is checked function insertConfirmButton(options) { var message = options.confirmMessage || '{% trans "Confirm" %}'; var confirm = ` ${message} `; $(options.modal).find('#modal-footer-buttons').append(confirm); // Disable the 'submit' button $(options.modal).find('#modal-form-submit').prop('disabled', true); // Trigger event $(options.modal).find('#modal-confirm').change(function() { var enabled = this.checked; $(options.modal).find('#modal-form-submit').prop('disabled', !enabled); }); } /* * Extract all specified form values as a single object */ function extractFormData(fields, options) { var data = {}; for (var idx = 0; idx < options.field_names.length; idx++) { var name = options.field_names[idx]; var field = fields[name] || null; if (!field) continue; if (field.type == 'candy') continue; data[name] = getFormFieldValue(name, field, options); } return data; } /* * Submit form data to the server. * */ function submitFormData(fields, options) { // Form data to be uploaded to the server // Only used if file / image upload is required var form_data = new FormData(); var data = {}; var has_files = false; // Extract values for each field for (var idx = 0; idx < options.field_names.length; idx++) { var name = options.field_names[idx]; var field = fields[name] || null; // Ignore visual fields if (field && field.type == 'candy') continue; if (field) { var value = getFormFieldValue(name, field, options); // Handle file inputs if (field.type == 'image upload' || field.type == 'file upload') { var field_el = $(options.modal).find(`#id_${name}`)[0]; var field_files = field_el.files; if (field_files.length > 0) { // One file per field, please! var file = field_files[0]; form_data.append(name, file); has_files = true; } } else { // Normal field (not a file or image) form_data.append(name, value); data[name] = value; } } else { console.log(`WARNING: Could not find field matching '${name}'`); } } var upload_func = inventreePut; if (has_files) { upload_func = inventreeFormDataUpload; data = form_data; } // Submit data upload_func( options.url, data, { method: options.method, success: function(response) { handleFormSuccess(response, options); }, error: function(xhr) { switch (xhr.status) { case 400: handleFormErrors(xhr.responseJSON, fields, options); break; default: $(options.modal).modal('hide'); showApiError(xhr); break; } } } ); } /* * Update (set) the field values based on the specified data. * * Iterate through each of the displayed fields, * and set the 'val' attribute of each one. * */ function updateFieldValues(fields, options) { for (var idx = 0; idx < options.field_names.length; idx++) { var name = options.field_names[idx]; var field = fields[name] || null; if (field == null) { continue; } var value = field.value; if (value == null) { value = field.default; } if (value == null) { continue; } updateFieldValue(name, value, field, options); } } /* * Update the value of a named field */ function updateFieldValue(name, value, field, options) { var el = $(options.modal).find(`#id_${name}`); if (!el) { console.log(`WARNING: updateFieldValue could not find field '${name}'`); return; } switch (field.type) { case 'boolean': el.prop('checked', value); break; case 'related field': // Clear? if (value == null && !field.required) { el.val(null).trigger('change'); } // TODO - Specify an actual value! break; case 'file upload': case 'image upload': break; default: el.val(value); break; } } /* * Extract and field value before sending back to the server * * arguments: * - name: The name of the field * - field: The field specification provided from the OPTIONS request * - options: The original options object provided by the client */ function getFormFieldValue(name, field, options) { // Find the HTML element var el = $(options.modal).find(`#id_${name}`); if (!el) { return null; } var value = null; switch (field.type) { case 'boolean': value = el.is(':checked'); break; case 'date': case 'datetime': value = el.val(); // Ensure empty values are sent as nulls if (!value || value.length == 0) { value = null; } break; default: value = el.val(); break; } return value; } /* * Handle successful form posting * * arguments: * - response: The JSON response object from the server * - options: The original options object provided by the client */ function handleFormSuccess(response, options) { // Close the modal if (!options.preventClose) { // Note: The modal will be deleted automatically after closing $(options.modal).modal('hide'); } // Display any required messages // Should we show alerts immediately or cache them? var cache = (options.follow && response.url) || options.redirect || options.reload; // Display any messages if (response && response.success) { showAlertOrCache('alert-success', response.success, cache); } if (response && response.info) { showAlertOrCache('alert-info', response.info, cache); } if (response && response.warning) { showAlertOrCache('alert-warning', response.warning, cache); } if (response && response.danger) { showAlertOrCache('alert-danger', response.danger, cache); } if (options.onSuccess) { // Callback function options.onSuccess(response, options); } if (options.follow && response.url) { // Follow the returned URL window.location.href = response.url; } else if (options.reload) { // Reload the current page location.reload(); } else if (options.redirect) { // Redirect to a specified URL window.location.href = options.redirect; } } /* * Remove all error text items from the form */ function clearFormErrors(options) { // Remove the individual error messages $(options.modal).find('.form-error-message').remove(); // Remove the "has error" class $(options.modal).find('.has-error').removeClass('has-error'); // Hide the 'non field errors' $(options.modal).find('#non-field-errors').html(''); } /* * Display form error messages as returned from the server, * specifically for errors returned in an array. * * We need to know the unique ID of each item in the array, * and the array length must equal the length of the array returned from the server * * arguments: * - response: The JSON error response from the server * - parent: The name of the parent field e.g. "items" * - options: The global options struct * * options: * - nested: A map of nested ID values for the "parent" field * e.g. * { * "items": [ * 1, * 2, * 12 * ] * } * */ function handleNestedErrors(errors, field_name, options) { var error_list = errors[field_name]; // Ignore null or empty list if (!error_list) { return; } var nest_list = nest_list = options['nested'][field_name]; // Nest list must be provided! if (!nest_list) { console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`); return; } for (var idx = 0; idx < error_list.length; idx++) { var error_item = error_list[idx]; if (idx >= nest_list.length) { console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`); break; } // Extract the particular ID of the nested item var nest_id = nest_list[idx]; // Here, error_item is a map of field names to error messages for (sub_field_name in error_item) { var errors = error_item[sub_field_name]; // Find the target (nested) field var target = `${field_name}_${sub_field_name}_${nest_id}`; for (var ii = errors.length-1; ii >= 0; ii--) { var error_text = errors[ii]; addFieldErrorMessage(target, error_text, ii, options); } } } } /* * Display form error messages as returned from the server. * * arguments: * - errors: The JSON error response from the server * - fields: The form data object * - options: Form options provided by the client */ function handleFormErrors(errors, fields, options) { // Reset the status of the "submit" button $(options.modal).find('#modal-form-submit').prop('disabled', false); // Remove any existing error messages from the form clearFormErrors(options); var non_field_errors = $(options.modal).find('#non-field-errors'); // TODO: Display the JSON error text when hovering over the "info" icon non_field_errors.append( `