mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
2279 lines
58 KiB
JavaScript
2279 lines
58 KiB
JavaScript
{% load i18n %}
|
|
{% load inventree_extras %}
|
|
|
|
/* globals
|
|
createNewModal,
|
|
inventreeFormDataUpload,
|
|
inventreeGet,
|
|
inventreePut,
|
|
modalEnable,
|
|
modalShowSubmitButton,
|
|
renderBuild,
|
|
renderCompany,
|
|
renderManufacturerPart,
|
|
renderOwner,
|
|
renderPart,
|
|
renderPartCategory,
|
|
renderPartParameterTemplate,
|
|
renderStockItem,
|
|
renderStockLocation,
|
|
renderSupplierPart,
|
|
renderUser,
|
|
showAlertOrCache,
|
|
showApiError,
|
|
*/
|
|
|
|
/* exported
|
|
clearFormInput,
|
|
disableFormInput,
|
|
enableFormInput,
|
|
hideFormInput,
|
|
setFormInputPlaceholder,
|
|
setFormGroupVisibility,
|
|
showFormInput,
|
|
*/
|
|
|
|
/**
|
|
*
|
|
* 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
|
|
*
|
|
*/
|
|
|
|
// Set global default theme for select2
|
|
$.fn.select2.defaults.set('theme', 'bootstrap-5');
|
|
|
|
/*
|
|
* 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) {
|
|
|
|
if (!url) {
|
|
return;
|
|
}
|
|
|
|
// Return the ajax request object
|
|
$.ajax({
|
|
url: url,
|
|
type: 'OPTIONS',
|
|
contentType: 'application/json',
|
|
dataType: 'json',
|
|
accepts: {
|
|
json: 'application/json',
|
|
},
|
|
success: callback,
|
|
error: function(xhr) {
|
|
// TODO: Handle error
|
|
console.log(`ERROR in getApiEndpointOptions at '${url}'`);
|
|
showApiError(xhr, 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,
|
|
data: options.params || {},
|
|
type: 'GET',
|
|
contentType: 'application/json',
|
|
dataType: 'json',
|
|
accepts: {
|
|
json: 'application/json',
|
|
},
|
|
success: function(data) {
|
|
|
|
// An optional function can be provided to process the returned results,
|
|
// before they are rendered to the form
|
|
if (options.processResults) {
|
|
var processed = options.processResults(data, fields, options);
|
|
|
|
// If the processResults function returns data, it will be stored
|
|
if (processed) {
|
|
data = processed;
|
|
}
|
|
}
|
|
|
|
// 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(xhr) {
|
|
// TODO: Handle error here
|
|
console.log(`ERROR in constructChangeForm at '${options.url}'`);
|
|
|
|
showApiError(xhr, 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(xhr) {
|
|
// TODO: Handle error here
|
|
console.log(`ERROR in constructDeleteForm at '${options.url}`);
|
|
|
|
showApiError(xhr, 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
|
|
showMessage('{% trans "Action Prohibited" %}', {
|
|
style: 'danger',
|
|
details: '{% trans "Create operation not allowed" %}',
|
|
icon: 'fas fa-user-times',
|
|
});
|
|
|
|
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
|
|
showMessage('{% trans "Action Prohibited" %}', {
|
|
style: 'danger',
|
|
details: '{% trans "Update operation not allowed" %}',
|
|
icon: 'fas fa-user-times',
|
|
});
|
|
|
|
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
|
|
showMessage('{% trans "Action Prohibited" %}', {
|
|
style: 'danger',
|
|
details: '{% trans "Delete operation not allowed" %}',
|
|
icon: 'fas fa-user-times',
|
|
});
|
|
|
|
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
|
|
showMessage('{% trans "Action Prohibited" %}', {
|
|
style: 'danger',
|
|
details: '{% trans "View operation not allowed" %}',
|
|
icon: 'fas fa-user-times',
|
|
});
|
|
|
|
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 = `<span class='fas ${val}'></span>`;
|
|
} 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 += `</div></div>`;
|
|
}
|
|
|
|
// 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);
|
|
|
|
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 html = `
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="modal-confirm">
|
|
<label class="form-check-label" for="modal-confirm">${message}</label>
|
|
</div>
|
|
`;
|
|
|
|
$(options.modal).find('#modal-footer-buttons').append(html);
|
|
|
|
// 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;
|
|
|
|
var data_valid = true;
|
|
|
|
var data_errors = {};
|
|
|
|
// 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) {
|
|
|
|
switch (field.type) {
|
|
// Ensure numerical fields are "valid"
|
|
case 'integer':
|
|
case 'float':
|
|
case 'decimal':
|
|
if (!validateFormField(name, options)) {
|
|
data_valid = false;
|
|
|
|
data_errors[name] = ['{% trans "Enter a valid number" %}'];
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
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}'`);
|
|
}
|
|
}
|
|
|
|
if (!data_valid) {
|
|
handleFormErrors(data_errors, fields, options);
|
|
return;
|
|
}
|
|
|
|
var upload_func = inventreePut;
|
|
|
|
if (has_files) {
|
|
upload_func = inventreeFormDataUpload;
|
|
data = form_data;
|
|
}
|
|
|
|
// Optionally pre-process the data before uploading to the server
|
|
if (options.processBeforeUpload) {
|
|
data = options.processBeforeUpload(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');
|
|
|
|
console.log(`upload error at ${options.url}`);
|
|
showApiError(xhr, options.url);
|
|
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 = getFormFieldElement(name, options);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
|
|
// Find the named field element in the modal DOM
|
|
function getFormFieldElement(name, options) {
|
|
|
|
var el = $(options.modal).find(`#id_${name}`);
|
|
|
|
if (!el.exists) {
|
|
console.log(`ERROR: Could not find form element for field '${name}'`);
|
|
}
|
|
|
|
return el;
|
|
}
|
|
|
|
|
|
/*
|
|
* Check that a "numerical" input field has a valid number in it.
|
|
* An invalid number is expunged at the client side by the getFormFieldValue() function,
|
|
* which means that an empty string '' is sent to the server if the number is not valud.
|
|
* This can result in confusing error messages displayed under the form field.
|
|
*
|
|
* So, we can invalid numbers and display errors *before* the form is submitted!
|
|
*/
|
|
function validateFormField(name, options) {
|
|
|
|
if (getFormFieldElement(name, options)) {
|
|
|
|
var el = document.getElementById(`id_${name}`);
|
|
|
|
if (el.validity.valueMissing) {
|
|
// Accept empty strings (server will validate)
|
|
return true;
|
|
} else {
|
|
return el.validity.valid;
|
|
}
|
|
} else {
|
|
return false;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/*
|
|
* 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 = getFormFieldElement(name, options);
|
|
|
|
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 || options.successMessage)) {
|
|
showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'});
|
|
}
|
|
|
|
if (response && response.info) {
|
|
showAlertOrCache(response.info, cache, {style: 'info'});
|
|
}
|
|
|
|
if (response && response.warning) {
|
|
showAlertOrCache(response.warning, cache, {style: 'warning'});
|
|
}
|
|
|
|
if (response && response.danger) {
|
|
showAlertOrCache(response.danger, cache, {style: 'danger'});
|
|
}
|
|
|
|
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('.form-field-error').removeClass('form-field-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(
|
|
`<div class='alert alert-block alert-danger'>
|
|
<b>{% trans "Form errors exist" %}</b>
|
|
<span id='form-errors-info' class='float-right fas fa-info-circle icon-red'>
|
|
</span>
|
|
</div>`
|
|
);
|
|
|
|
// Non-field errors?
|
|
if ('non_field_errors' in errors) {
|
|
|
|
var nfe = errors.non_field_errors;
|
|
|
|
for (var idx = 0; idx < nfe.length; idx++) {
|
|
var err = nfe[idx];
|
|
|
|
var html = `
|
|
<div class='alert alert-block alert-danger'>
|
|
${err}
|
|
</div>`;
|
|
|
|
non_field_errors.append(html);
|
|
}
|
|
}
|
|
|
|
var first_error_field = null;
|
|
|
|
for (var field_name in errors) {
|
|
|
|
if (field_name in fields) {
|
|
|
|
var field = fields[field_name];
|
|
|
|
if ((field.type == 'field') && ('child' in field)) {
|
|
// This is a "nested" field
|
|
handleNestedErrors(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)) {
|
|
first_error_field = field_name;
|
|
}
|
|
|
|
// Add an entry for each returned error message
|
|
for (var ii = field_errors.length-1; ii >= 0; ii--) {
|
|
|
|
var error_text = field_errors[ii];
|
|
|
|
addFieldErrorMessage(field_name, error_text, ii, options);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (first_error_field) {
|
|
// Ensure that the field in question is visible
|
|
document.querySelector(`#div_id_${field_name}`).scrollIntoView({
|
|
behavior: 'smooth',
|
|
});
|
|
} else {
|
|
// Scroll to the top of the form
|
|
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
|
|
}
|
|
|
|
$(options.modal).find('.modal-content').addClass('modal-error');
|
|
}
|
|
|
|
|
|
/*
|
|
* Add a rendered error message to the provided field
|
|
*/
|
|
function addFieldErrorMessage(field_name, error_text, error_idx, options) {
|
|
|
|
// Add the 'form-field-error' class
|
|
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
|
|
|
|
var field_dom = $(options.modal).find(`#errors-${field_name}`);
|
|
|
|
if (field_dom) {
|
|
|
|
var error_html = `
|
|
<span id='error_${error_idx}_id_${field_name}' class='help-block form-error-message'>
|
|
<strong>${error_text}</strong>
|
|
</span>`;
|
|
|
|
field_dom.append(error_html);
|
|
} else {
|
|
console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`);
|
|
}
|
|
}
|
|
|
|
|
|
function isFieldVisible(field, options) {
|
|
|
|
return $(options.modal).find(`#div_id_${field}`).is(':visible');
|
|
}
|
|
|
|
|
|
/*
|
|
* Attach callbacks to specified fields,
|
|
* triggered after the field value is edited.
|
|
*
|
|
* Callback function is called with arguments (name, field, options)
|
|
*/
|
|
function addFieldCallbacks(fields, options) {
|
|
|
|
for (var idx = 0; idx < options.field_names.length; idx++) {
|
|
|
|
var name = options.field_names[idx];
|
|
|
|
var field = fields[name];
|
|
|
|
if (!field || !field.onEdit) continue;
|
|
|
|
addFieldCallback(name, field, options);
|
|
}
|
|
}
|
|
|
|
|
|
function addFieldCallback(name, field, options) {
|
|
|
|
var el = getFormFieldElement(name, options);
|
|
|
|
el.change(function() {
|
|
|
|
var value = getFormFieldValue(name, field, options);
|
|
|
|
field.onEdit(value, name, field, options);
|
|
});
|
|
}
|
|
|
|
|
|
function addClearCallbacks(fields, options) {
|
|
|
|
for (var idx = 0; idx < options.field_names.length; idx++) {
|
|
|
|
var name = options.field_names[idx];
|
|
|
|
var field = fields[name];
|
|
|
|
if (!field || field.required) continue;
|
|
|
|
addClearCallback(name, field, options);
|
|
}
|
|
}
|
|
|
|
|
|
function addClearCallback(name, field, options) {
|
|
|
|
var el = $(options.modal).find(`#clear_${name}`);
|
|
|
|
if (!el) {
|
|
console.log(`WARNING: addClearCallback could not find field '${name}'`);
|
|
return;
|
|
}
|
|
|
|
el.click(function() {
|
|
updateFieldValue(name, null, field, options);
|
|
});
|
|
}
|
|
|
|
|
|
// Initialize callbacks and initial states for groups
|
|
function initializeGroups(fields, options) {
|
|
|
|
var modal = options.modal;
|
|
|
|
// Callback for when the group is expanded
|
|
$(modal).find('.form-panel-content').on('show.bs.collapse', function() {
|
|
|
|
var panel = $(this).closest('.form-panel');
|
|
var group = panel.attr('group');
|
|
|
|
var icon = $(modal).find(`#group-icon-${group}`);
|
|
|
|
icon.removeClass('fa-angle-right');
|
|
icon.addClass('fa-angle-up');
|
|
});
|
|
|
|
// Callback for when the group is collapsed
|
|
$(modal).find('.form-panel-content').on('hide.bs.collapse', function() {
|
|
|
|
var panel = $(this).closest('.form-panel');
|
|
var group = panel.attr('group');
|
|
|
|
var icon = $(modal).find(`#group-icon-${group}`);
|
|
|
|
icon.removeClass('fa-angle-up');
|
|
icon.addClass('fa-angle-right');
|
|
});
|
|
|
|
// Set initial state of each specified group
|
|
for (var group in options.groups) {
|
|
|
|
var group_options = options.groups[group];
|
|
|
|
if (group_options.collapsed) {
|
|
$(modal).find(`#form-panel-content-${group}`).collapse('hide');
|
|
} else {
|
|
$(modal).find(`#form-panel-content-${group}`).collapse('show');
|
|
}
|
|
|
|
if (group_options.hidden) {
|
|
hideFormGroup(group, options);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set the placeholder value for a field
|
|
function setFormInputPlaceholder(name, placeholder, options) {
|
|
$(options.modal).find(`#id_${name}`).attr('placeholder', placeholder);
|
|
}
|
|
|
|
// Clear a form input
|
|
function clearFormInput(name, options) {
|
|
updateFieldValue(name, null, {}, options);
|
|
}
|
|
|
|
// Disable a form input
|
|
function disableFormInput(name, options) {
|
|
$(options.modal).find(`#id_${name}`).prop('disabled', true);
|
|
}
|
|
|
|
|
|
// Enable a form input
|
|
function enableFormInput(name, options) {
|
|
$(options.modal).find(`#id_${name}`).prop('disabled', false);
|
|
}
|
|
|
|
|
|
// Hide a form input
|
|
function hideFormInput(name, options) {
|
|
$(options.modal).find(`#div_id_${name}`).hide();
|
|
}
|
|
|
|
|
|
// Show a form input
|
|
function showFormInput(name, options) {
|
|
$(options.modal).find(`#div_id_${name}`).show();
|
|
}
|
|
|
|
|
|
// Hide a form group
|
|
function hideFormGroup(group, options) {
|
|
$(options.modal).find(`#form-panel-${group}`).hide();
|
|
}
|
|
|
|
// Show a form group
|
|
function showFormGroup(group, options) {
|
|
$(options.modal).find(`#form-panel-${group}`).show();
|
|
}
|
|
|
|
function setFormGroupVisibility(group, vis, options) {
|
|
if (vis) {
|
|
showFormGroup(group, options);
|
|
} else {
|
|
hideFormGroup(group, options);
|
|
}
|
|
}
|
|
|
|
|
|
function initializeRelatedFields(fields, options) {
|
|
|
|
var field_names = options.field_names;
|
|
|
|
for (var idx = 0; idx < field_names.length; idx++) {
|
|
|
|
var name = field_names[idx];
|
|
|
|
var field = fields[name] || null;
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* Add a button to launch a secondary modal, to create a new modal instance.
|
|
*
|
|
* arguments:
|
|
* - name: The name of the field
|
|
* - field: The field data object
|
|
* - options: The options object provided by the client
|
|
*/
|
|
function addSecondaryModal(field, fields, options) {
|
|
|
|
var name = field.name;
|
|
|
|
var secondary = field.secondary;
|
|
|
|
var html = `
|
|
<span style='float: right;'>
|
|
<div type='button' class='btn btn-primary btn-secondary btn-form-secondary' title='${secondary.title || secondary.label}' id='btn-new-${name}'>
|
|
${secondary.label || secondary.title}
|
|
</div>
|
|
</span>`;
|
|
|
|
$(options.modal).find(`label[for="id_${name}"]`).append(html);
|
|
|
|
// Callback function when the secondary button is pressed
|
|
$(options.modal).find(`#btn-new-${name}`).click(function() {
|
|
|
|
// Determine the API query URL
|
|
var url = secondary.api_url || field.api_url;
|
|
|
|
// If the "fields" attribute is a function, call it with data
|
|
if (secondary.fields instanceof Function) {
|
|
|
|
// Extract form values at time of button press
|
|
var data = extractFormData(fields, options);
|
|
|
|
secondary.fields = secondary.fields(data);
|
|
}
|
|
|
|
// If no onSuccess function is defined, provide a default one
|
|
if (!secondary.onSuccess) {
|
|
secondary.onSuccess = function(data) {
|
|
|
|
// Force refresh from the API, to get full detail
|
|
inventreeGet(`${url}${data.pk}/`, {}, {
|
|
success: function(responseData) {
|
|
|
|
setRelatedFieldData(name, responseData, options);
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
// Method should be "POST" for creation
|
|
secondary.method = secondary.method || 'POST';
|
|
|
|
constructForm(
|
|
url,
|
|
secondary
|
|
);
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Initialize a single related-field
|
|
*
|
|
* argument:
|
|
* - modal: DOM identifier for the modal window
|
|
* - name: name of the field e.g. 'location'
|
|
* - field: Field definition from the OPTIONS request
|
|
* - options: Original options object provided by the client
|
|
*/
|
|
function initializeRelatedField(field, fields, options) {
|
|
|
|
var name = field.name;
|
|
|
|
if (!field.api_url) {
|
|
// TODO: Provide manual api_url option?
|
|
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
|
|
return;
|
|
}
|
|
|
|
// Find the select element and attach a select2 to it
|
|
var select = getFormFieldElement(name, options);
|
|
|
|
// Add a button to launch a 'secondary' modal
|
|
if (field.secondary != null) {
|
|
addSecondaryModal(field, fields, options);
|
|
}
|
|
|
|
// TODO: Add 'placeholder' support for entry select2 fields
|
|
|
|
// limit size for AJAX requests
|
|
var pageSize = options.pageSize || 25;
|
|
|
|
select.select2({
|
|
placeholder: '',
|
|
dropdownParent: $(options.modal),
|
|
dropdownAutoWidth: false,
|
|
language: {
|
|
noResults: function(query) {
|
|
if (field.noResults) {
|
|
return field.noResults(query);
|
|
} else {
|
|
return '{% trans "No results found" %}';
|
|
}
|
|
}
|
|
},
|
|
ajax: {
|
|
url: field.api_url,
|
|
dataType: 'json',
|
|
delay: 250,
|
|
cache: true,
|
|
data: function(params) {
|
|
|
|
var offset = 0;
|
|
|
|
if (!params.page) {
|
|
offset = 0;
|
|
} else {
|
|
offset = (params.page - 1) * pageSize;
|
|
}
|
|
|
|
// Custom query filters can be specified against each field
|
|
var query = field.filters || {};
|
|
|
|
// Add search and pagination options
|
|
query.search = params.term;
|
|
query.offset = offset;
|
|
query.limit = pageSize;
|
|
|
|
// Allow custom run-time filter augmentation
|
|
if ('adjustFilters' in field) {
|
|
query = field.adjustFilters(query, options);
|
|
}
|
|
|
|
return query;
|
|
},
|
|
processResults: function(response) {
|
|
// Convert the returned InvenTree data into select2-friendly format
|
|
|
|
var data = [];
|
|
|
|
var more = false;
|
|
|
|
if ('count' in response && 'results' in response) {
|
|
// Response is paginated
|
|
data = response.results;
|
|
|
|
// Any more data available?
|
|
if (response.next) {
|
|
more = true;
|
|
}
|
|
|
|
} else {
|
|
// Non-paginated response
|
|
data = response;
|
|
}
|
|
|
|
// Each 'row' must have the 'id' attribute
|
|
for (var idx = 0; idx < data.length; idx++) {
|
|
data[idx].id = data[idx].pk;
|
|
}
|
|
|
|
// Ref: https://select2.org/data-sources/formats
|
|
var results = {
|
|
results: data,
|
|
pagination: {
|
|
more: more,
|
|
}
|
|
};
|
|
|
|
return results;
|
|
},
|
|
},
|
|
templateResult: function(item) {
|
|
|
|
// Extract 'instance' data passed through from an initial value
|
|
// Or, use the raw 'item' data as a backup
|
|
var data = item;
|
|
|
|
if (item.element && item.element.instance) {
|
|
data = item.element.instance;
|
|
}
|
|
|
|
if (!data.pk) {
|
|
return $(searching());
|
|
}
|
|
|
|
// Custom formatting for the search results
|
|
if (field.model) {
|
|
// If the 'model' is specified, hand it off to the custom model render
|
|
var html = renderModelData(name, field.model, data, field, options);
|
|
return $(html);
|
|
} else {
|
|
// Return a simple renderering
|
|
console.log(`WARNING: templateResult() missing 'field.model' for '${name}'`);
|
|
return `${name} - ${item.id}`;
|
|
}
|
|
},
|
|
templateSelection: function(item) {
|
|
|
|
// Extract 'instance' data passed through from an initial value
|
|
// Or, use the raw 'item' data as a backup
|
|
var data = item;
|
|
|
|
if (item.element && item.element.instance) {
|
|
data = item.element.instance;
|
|
}
|
|
|
|
// Run optional callback function
|
|
if (field.onSelect && data) {
|
|
field.onSelect(data, field, options);
|
|
}
|
|
|
|
if (!data.pk) {
|
|
return field.placeholder || '';
|
|
}
|
|
|
|
// Custom formatting for selected item
|
|
if (field.model) {
|
|
// If the 'model' is specified, hand it off to the custom model render
|
|
var html = renderModelData(name, field.model, data, field, options);
|
|
return $(html);
|
|
} else {
|
|
// Return a simple renderering
|
|
console.log(`WARNING: templateSelection() missing 'field.model' for '${name}'`);
|
|
return `${name} - ${item.id}`;
|
|
}
|
|
}
|
|
});
|
|
|
|
// If a 'value' is already defined, grab the model info from the server
|
|
if (field.value) {
|
|
|
|
var pk = field.value;
|
|
var url = `${field.api_url}/${pk}/`.replace('//', '/');
|
|
|
|
inventreeGet(url, field.filters || {}, {
|
|
success: function(data) {
|
|
setRelatedFieldData(name, data, options);
|
|
}
|
|
});
|
|
} else if (field.auto_fill) {
|
|
// Attempt to auto-fill the field
|
|
|
|
var filters = field.filters || {};
|
|
|
|
// Enforce pagination, limit to a single return (for fast query)
|
|
filters.limit = 1;
|
|
filters.offset = 0;
|
|
|
|
inventreeGet(field.api_url, field.filters || {}, {
|
|
success: function(data) {
|
|
|
|
// Only a single result is available, given the provided filters
|
|
if (data.count == 1) {
|
|
setRelatedFieldData(name, data.results[0], options);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* Set the value of a select2 instace for a "related field",
|
|
* e.g. with data returned from a secondary modal
|
|
*
|
|
* arguments:
|
|
* - name: The name of the field
|
|
* - data: JSON data representing the model instance
|
|
* - options: The modal form specifications
|
|
*/
|
|
function setRelatedFieldData(name, data, options) {
|
|
|
|
var select = getFormFieldElement(name, options);
|
|
|
|
var option = new Option(name, data.pk, true, true);
|
|
|
|
// Assign the JSON data to the 'instance' attribute,
|
|
// so we can access and render it later
|
|
option.instance = data;
|
|
|
|
select.append(option).trigger('change');
|
|
|
|
select.trigger({
|
|
type: 'select2:select',
|
|
params: {
|
|
data: data
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function initializeChoiceField(field, fields, options) {
|
|
|
|
var select = getFormFieldElement(field.name, options);
|
|
|
|
select.select2({
|
|
dropdownAutoWidth: false,
|
|
dropdownParent: $(options.modal),
|
|
});
|
|
}
|
|
|
|
|
|
// Render a 'no results' element
|
|
function searching() {
|
|
return `<span>{% trans "Searching" %}...</span>`;
|
|
}
|
|
|
|
/*
|
|
* Render a "foreign key" model reference in a select2 instance.
|
|
* Allows custom rendering with access to the entire serialized object.
|
|
*
|
|
* arguments:
|
|
* - name: The name of the field e.g. 'location'
|
|
* - model: The name of the InvenTree model e.g. 'stockitem'
|
|
* - data: The JSON data representation of the modal instance (GET request)
|
|
* - parameters: The field definition (OPTIONS) request
|
|
* - options: Other options provided at time of modal creation by the client
|
|
*/
|
|
function renderModelData(name, model, data, parameters, options) {
|
|
|
|
if (!data) {
|
|
return parameters.placeholder || '';
|
|
}
|
|
|
|
// TODO: Implement this function for various models
|
|
|
|
var html = null;
|
|
|
|
var renderer = null;
|
|
|
|
// Find a custom renderer
|
|
switch (model) {
|
|
case 'company':
|
|
renderer = renderCompany;
|
|
break;
|
|
case 'stockitem':
|
|
renderer = renderStockItem;
|
|
break;
|
|
case 'stocklocation':
|
|
renderer = renderStockLocation;
|
|
break;
|
|
case 'part':
|
|
renderer = renderPart;
|
|
break;
|
|
case 'partcategory':
|
|
renderer = renderPartCategory;
|
|
break;
|
|
case 'partparametertemplate':
|
|
renderer = renderPartParameterTemplate;
|
|
break;
|
|
case 'salesorder':
|
|
renderer = renderSalesOrder;
|
|
break;
|
|
case 'manufacturerpart':
|
|
renderer = renderManufacturerPart;
|
|
break;
|
|
case 'supplierpart':
|
|
renderer = renderSupplierPart;
|
|
break;
|
|
case 'build':
|
|
renderer = renderBuild;
|
|
break;
|
|
case 'owner':
|
|
renderer = renderOwner;
|
|
break;
|
|
case 'user':
|
|
renderer = renderUser;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (renderer != null) {
|
|
html = renderer(name, data, parameters, options);
|
|
}
|
|
|
|
if (html != null) {
|
|
return html;
|
|
} else {
|
|
console.log(`ERROR: Rendering not implemented for model '${model}'`);
|
|
// Simple text rendering
|
|
return `${model} - ID ${data.id}`;
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* Construct a single form 'field' for rendering in a form.
|
|
*
|
|
* arguments:
|
|
* - name: The 'name' of the field
|
|
* - parameters: The field parameters supplied by the DRF OPTIONS method
|
|
*
|
|
* options:
|
|
* -
|
|
*
|
|
* The function constructs a fieldset which mostly replicates django "crispy" forms:
|
|
*
|
|
* - Field name
|
|
* - Field <input> (depends on specified field type)
|
|
* - Field description (help text)
|
|
* - Field errors
|
|
*/
|
|
function constructField(name, parameters, options) {
|
|
|
|
var html = '';
|
|
|
|
// Shortcut for simple visual fields
|
|
if (parameters.type == 'candy') {
|
|
return constructCandyInput(name, parameters, options);
|
|
}
|
|
|
|
var field_name = `id_${name}`;
|
|
|
|
// Hidden inputs are rendered without label / help text / etc
|
|
if (parameters.hidden) {
|
|
return constructHiddenInput(name, parameters, options);
|
|
}
|
|
|
|
// Are we ending a group?
|
|
if (options.current_group && parameters.group != options.current_group) {
|
|
html += `</div></div>`;
|
|
|
|
// Null out the current "group" so we can start a new one
|
|
options.current_group = null;
|
|
}
|
|
|
|
// Are we starting a new group?
|
|
if (parameters.group) {
|
|
|
|
var group = parameters.group;
|
|
|
|
var group_options = options.groups[group] || {};
|
|
|
|
// Are we starting a new group?
|
|
// Add HTML for the start of a separate panel
|
|
if (parameters.group != options.current_group) {
|
|
|
|
html += `
|
|
<div class='panel form-panel' id='form-panel-${group}' group='${group}'>
|
|
<div class='panel-heading form-panel-heading' id='form-panel-heading-${group}'>`;
|
|
if (group_options.collapsible) {
|
|
html += `
|
|
<div data-bs-toggle='collapse' data-bs-target='#form-panel-content-${group}'>
|
|
<a href='#'><span id='group-icon-${group}' class='fas fa-angle-up'></span>
|
|
`;
|
|
} else {
|
|
html += `<div>`;
|
|
}
|
|
|
|
html += `<h4 style='display: inline;'>${group_options.title || group}</h4>`;
|
|
|
|
if (group_options.collapsible) {
|
|
html += `</a>`;
|
|
}
|
|
|
|
html += `
|
|
</div></div>
|
|
<div class='panel-content form-panel-content' id='form-panel-content-${group}'>
|
|
`;
|
|
}
|
|
|
|
// Keep track of the group we are in
|
|
options.current_group = group;
|
|
}
|
|
|
|
var form_classes = 'form-group';
|
|
|
|
if (parameters.errors) {
|
|
form_classes += ' form-field-error';
|
|
}
|
|
|
|
// Optional content to render before the field
|
|
if (parameters.before) {
|
|
html += parameters.before;
|
|
}
|
|
|
|
html += `<div id='div_${field_name}' class='${form_classes}'>`;
|
|
|
|
// Add a label
|
|
if (!options.hideLabels) {
|
|
html += constructLabel(name, parameters);
|
|
}
|
|
|
|
html += `<div class='controls'>`;
|
|
|
|
// Does this input deserve "extra" decorators?
|
|
var extra = parameters.prefix != null;
|
|
|
|
// Some fields can have 'clear' inputs associated with them
|
|
if (!parameters.required && !parameters.read_only) {
|
|
switch (parameters.type) {
|
|
case 'string':
|
|
case 'url':
|
|
case 'email':
|
|
case 'integer':
|
|
case 'float':
|
|
case 'decimal':
|
|
case 'related field':
|
|
case 'date':
|
|
extra = true;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (extra) {
|
|
html += `<div class='input-group'>`;
|
|
|
|
if (parameters.prefix) {
|
|
html += `<span class='input-group-text'>${parameters.prefix}</span>`;
|
|
}
|
|
}
|
|
|
|
html += constructInput(name, parameters, options);
|
|
|
|
if (extra) {
|
|
|
|
if (!parameters.required) {
|
|
html += `
|
|
<span class='input-group-text form-clear' id='clear_${name}' title='{% trans "Clear input" %}'>
|
|
<span class='icon-red fas fa-backspace'></span>
|
|
</span>`;
|
|
}
|
|
|
|
html += `</div>`; // input-group
|
|
}
|
|
|
|
if (parameters.help_text && !options.hideLabels) {
|
|
|
|
// Boolean values are handled differently!
|
|
if (parameters.type != 'boolean') {
|
|
html += constructHelpText(name, parameters, options);
|
|
}
|
|
}
|
|
|
|
// Div for error messages
|
|
html += `<div id='errors-${name}'></div>`;
|
|
|
|
|
|
html += `</div>`; // controls
|
|
html += `</div>`; // form-group
|
|
|
|
if (parameters.after) {
|
|
html += parameters.after;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
|
|
/*
|
|
* Construct a 'label' div
|
|
*
|
|
* arguments:
|
|
* - name: The name of the field
|
|
* - required: Is this a required field?
|
|
*/
|
|
function constructLabel(name, parameters) {
|
|
|
|
var label_classes = 'control-label';
|
|
|
|
if (parameters.required) {
|
|
label_classes += ' requiredField';
|
|
}
|
|
|
|
var html = `<label class='${label_classes}' for='id_${name}'>`;
|
|
|
|
if (parameters.label) {
|
|
html += `${parameters.label}`;
|
|
} else {
|
|
html += `${name}`;
|
|
}
|
|
|
|
if (parameters.required) {
|
|
html += `<span class='asteriskField'>*</span>`;
|
|
}
|
|
|
|
html += `</label>`;
|
|
|
|
return html;
|
|
}
|
|
|
|
|
|
/*
|
|
* Construct a form input based on the field parameters
|
|
*
|
|
* arguments:
|
|
* - name: The name of the field
|
|
* - parameters: Field parameters returned by the OPTIONS method
|
|
*
|
|
*/
|
|
function constructInput(name, parameters, options) {
|
|
|
|
var html = '';
|
|
|
|
var func = null;
|
|
|
|
switch (parameters.type) {
|
|
case 'boolean':
|
|
func = constructCheckboxInput;
|
|
break;
|
|
case 'string':
|
|
case 'url':
|
|
case 'email':
|
|
func = constructTextInput;
|
|
break;
|
|
case 'integer':
|
|
case 'float':
|
|
case 'decimal':
|
|
func = constructNumberInput;
|
|
break;
|
|
case 'choice':
|
|
func = constructChoiceInput;
|
|
break;
|
|
case 'related field':
|
|
func = constructRelatedFieldInput;
|
|
break;
|
|
case 'image upload':
|
|
case 'file upload':
|
|
func = constructFileUploadInput;
|
|
break;
|
|
case 'date':
|
|
func = constructDateInput;
|
|
break;
|
|
case 'candy':
|
|
func = constructCandyInput;
|
|
break;
|
|
case 'raw':
|
|
func = constructRawInput;
|
|
default:
|
|
// Unsupported field type!
|
|
break;
|
|
}
|
|
|
|
if (func != null) {
|
|
html = func(name, parameters, options);
|
|
} else {
|
|
console.log(`WARNING: Unhandled form field type: '${parameters.type}'`);
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
|
|
// Construct a set of default input options which apply to all input types
|
|
function constructInputOptions(name, classes, type, parameters) {
|
|
|
|
var opts = [];
|
|
|
|
opts.push(`id='id_${name}'`);
|
|
|
|
opts.push(`class='${classes}'`);
|
|
|
|
opts.push(`name='${name}'`);
|
|
|
|
opts.push(`type='${type}'`);
|
|
|
|
// Read only?
|
|
if (parameters.read_only) {
|
|
opts.push(`readonly=''`);
|
|
}
|
|
|
|
if (parameters.value != null) {
|
|
// Existing value?
|
|
opts.push(`value='${parameters.value}'`);
|
|
} else if (parameters.default != null) {
|
|
// Otherwise, a defualt value?
|
|
opts.push(`value='${parameters.default}'`);
|
|
}
|
|
|
|
// Maximum input length
|
|
if (parameters.max_length != null) {
|
|
opts.push(`maxlength='${parameters.max_length}'`);
|
|
}
|
|
|
|
// Minimum input length
|
|
if (parameters.min_length != null) {
|
|
opts.push(`minlength='${parameters.min_length}'`);
|
|
}
|
|
|
|
// Maximum value
|
|
if (parameters.max_value != null) {
|
|
opts.push(`max='${parameters.max_value}'`);
|
|
}
|
|
|
|
// Minimum value
|
|
if (parameters.min_value != null) {
|
|
opts.push(`min='${parameters.min_value}'`);
|
|
}
|
|
|
|
// Field is required?
|
|
if (parameters.required) {
|
|
opts.push(`required=''`);
|
|
}
|
|
|
|
// Custom mouseover title?
|
|
if (parameters.title != null) {
|
|
opts.push(`title='${parameters.title}'`);
|
|
}
|
|
|
|
// Placeholder?
|
|
if (parameters.placeholder != null) {
|
|
opts.push(`placeholder='${parameters.placeholder}'`);
|
|
}
|
|
|
|
switch (parameters.type) {
|
|
case 'boolean':
|
|
break;
|
|
case 'integer':
|
|
case 'float':
|
|
case 'decimal':
|
|
opts.push(`step='any'`);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (parameters.multiline) {
|
|
return `<textarea ${opts.join(' ')}></textarea>`;
|
|
} else if (parameters.type == 'boolean') {
|
|
return `
|
|
<div class='form-check form-switch'>
|
|
<input ${opts.join(' ')}>
|
|
<label class='form-check-label' for=''>
|
|
<em><small>${parameters.help_text}</small></em>
|
|
</label>
|
|
</div>
|
|
`;
|
|
} else {
|
|
return `<input ${opts.join(' ')}>`;
|
|
}
|
|
}
|
|
|
|
|
|
// Construct a "hidden" input
|
|
function constructHiddenInput(name, parameters) {
|
|
|
|
return constructInputOptions(
|
|
name,
|
|
'hiddeninput',
|
|
'hidden',
|
|
parameters
|
|
);
|
|
}
|
|
|
|
|
|
// Construct a "checkbox" input
|
|
function constructCheckboxInput(name, parameters) {
|
|
|
|
return constructInputOptions(
|
|
name,
|
|
'form-check-input',
|
|
'checkbox',
|
|
parameters
|
|
);
|
|
}
|
|
|
|
|
|
// Construct a "text" input
|
|
function constructTextInput(name, parameters) {
|
|
|
|
var classes = '';
|
|
var type = '';
|
|
|
|
switch (parameters.type) {
|
|
default:
|
|
classes = 'textinput textInput form-control';
|
|
type = 'text';
|
|
break;
|
|
case 'url':
|
|
classes = 'urlinput form-control';
|
|
type = 'url';
|
|
break;
|
|
case 'email':
|
|
classes = 'emailinput form-control';
|
|
type = 'email';
|
|
break;
|
|
}
|
|
|
|
return constructInputOptions(
|
|
name,
|
|
classes,
|
|
type,
|
|
parameters
|
|
);
|
|
}
|
|
|
|
|
|
// Construct a "number" field
|
|
function constructNumberInput(name, parameters) {
|
|
|
|
return constructInputOptions(
|
|
name,
|
|
'numberinput form-control',
|
|
'number',
|
|
parameters
|
|
);
|
|
}
|
|
|
|
|
|
// Construct a "choice" input
|
|
function constructChoiceInput(name, parameters) {
|
|
|
|
var html = `<select id='id_${name}' class='select form-control' name='${name}'>`;
|
|
|
|
var choices = parameters.choices || [];
|
|
|
|
for (var idx = 0; idx < choices.length; idx++) {
|
|
|
|
var choice = choices[idx];
|
|
|
|
var selected = '';
|
|
|
|
if (parameters.value && parameters.value == choice.value) {
|
|
selected = ` selected=''`;
|
|
}
|
|
|
|
html += `<option value='${choice.value}'${selected}>`;
|
|
html += `${choice.display_name}`;
|
|
html += `</option>`;
|
|
}
|
|
|
|
html += `</select>`;
|
|
|
|
return html;
|
|
}
|
|
|
|
|
|
/*
|
|
* Construct a "related field" input.
|
|
* This will create a "select" input which will then, (after form is loaded),
|
|
* be converted into a select2 input.
|
|
* This will then be served custom data from the API (as required)...
|
|
*/
|
|
function constructRelatedFieldInput(name) {
|
|
|
|
var html = `<select id='id_${name}' class='select form-control' name='${name}'></select>`;
|
|
|
|
// Don't load any options - they will be filled via an AJAX request
|
|
|
|
return html;
|
|
}
|
|
|
|
|
|
/*
|
|
* Construct a field for file upload
|
|
*/
|
|
function constructFileUploadInput(name, parameters) {
|
|
|
|
var cls = 'clearablefileinput';
|
|
|
|
if (parameters.required) {
|
|
cls = 'fileinput';
|
|
}
|
|
|
|
return constructInputOptions(
|
|
name,
|
|
cls,
|
|
'file',
|
|
parameters
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* Construct a field for a date input
|
|
*/
|
|
function constructDateInput(name, parameters) {
|
|
|
|
return constructInputOptions(
|
|
name,
|
|
'dateinput form-control',
|
|
'date',
|
|
parameters
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* Construct a "candy" field input
|
|
* No actual field data!
|
|
*/
|
|
function constructCandyInput(name, parameters) {
|
|
|
|
return parameters.html;
|
|
|
|
}
|
|
|
|
|
|
/*
|
|
* Construct a "raw" field input
|
|
* No actual field data!
|
|
*/
|
|
function constructRawInput(name, parameters) {
|
|
|
|
return parameters.html;
|
|
|
|
}
|
|
|
|
|
|
/*
|
|
* Construct a 'help text' div based on the field parameters
|
|
*
|
|
* arguments:
|
|
* - name: The name of the field
|
|
* - parameters: Field parameters returned by the OPTIONS method
|
|
*
|
|
*/
|
|
function constructHelpText(name, parameters) {
|
|
|
|
var html = `<div id='hint_id_${name}' class='help-block'><i>${parameters.help_text}</i></div>`;
|
|
|
|
return html;
|
|
}
|