/*global DateTimeShortcuts, SelectFilter*/ /** * Django admin inlines * * Based on jQuery Formset 1.1 * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) * @requires jQuery 1.2.6 or later * * Copyright (c) 2009, Stanislaus Madueke * All rights reserved. * * Spiced up with Code from Zain Memon's GSoC project 2009 * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. * * Licensed under the New BSD License * See: https://opensource.org/licenses/bsd-license.php */ "use strict"; { const $ = django.jQuery; $.fn.formset = function (opts) { const options = $.extend({}, $.fn.formset.defaults, opts); const $this = $(this); const $parent = $this.parent(); const updateElementIndex = function (el, prefix, ndx) { const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); const replacement = prefix + "-" + ndx; if ($(el).prop("for")) { $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); } if (el.id) { el.id = el.id.replace(id_regex, replacement); } if (el.name) { // !CHANGED from original // el.name = el.name.replace(id_regex, replacement); el.setAttribute("name", el.name.replace(id_regex, replacement)); } }; const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop( "autocomplete", "off" ); let nextIndex = parseInt(totalForms.val(), 10); const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop( "autocomplete", "off" ); const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop( "autocomplete", "off" ); let addButton; /** * The "Add another MyModel" button below the inline forms. */ const addInlineAddButton = function () { if (addButton === null) { if ($this.prop("tagName") === "TR") { // If forms are laid out as table rows, insert the // "add" button in a new table row: const numCols = $this.eq(-1).children().length; $parent.append( '' + options.addText + "" ); addButton = $parent.find("tr:last a"); } else { // Otherwise, insert it immediately after the last form: $this .filter(":last") .after( '
' + options.addText + "
" ); addButton = $this.filter(":last").next().find("a"); } } addButton.on("click", addInlineClickHandler); }; const addInlineClickHandler = function (e) { e.preventDefault(); const template = $("#" + options.prefix + "-empty"); const row = template.clone(true); row .removeClass(options.emptyCssClass) .addClass(options.formCssClass) .attr("id", options.prefix + "-" + nextIndex); addInlineDeleteButton(row); row.find("*").each(function () { updateElementIndex(this, options.prefix, totalForms.val()); }); // Insert the new form when it has been fully edited. // !CHANGED from original if ($(template).parent().is("tbody")) { row .wrap('') .parent() .insertBefore($(template).parent()); } else { row.insertBefore($(template)); } // Update number of total forms. $(totalForms).val(parseInt(totalForms.val(), 10) + 1); nextIndex += 1; // Hide the add button if there's a limit and it's been reached. if (maxForms.val() !== "" && maxForms.val() - totalForms.val() <= 0) { addButton.parent().hide(); } // Show the remove buttons if there are more than min_num. toggleDeleteButtonVisibility(row.closest(".inline-group")); // Pass the new form to the post-add callback, if provided. if (options.added) { options.added(row); } row.get(0).dispatchEvent( new CustomEvent("formset:added", { bubbles: true, detail: { formsetName: options.prefix, }, }) ); }; /** * The "X" button that is part of every unsaved inline. * (When saved, it is replaced with a "Delete" checkbox.) */ const addInlineDeleteButton = function (row) { if (row.is("tr")) { // If the forms are laid out in table rows, insert // the remove button into the last table cell: row .children(":last") .append( '
' + options.deleteText + "
" ); } else if (row.is("ul") || row.is("ol")) { // If they're laid out as an ordered/unordered list, // insert an
  • after the last list item: row.append( '
  • ' + options.deleteText + "
  • " ); } else { // Otherwise, just insert the remove button as the // last child element of the form's container: row .children(":first") .append( '' + options.deleteText + "" ); } // Add delete handler for each row. row .find("a." + options.deleteCssClass) .on("click", inlineDeleteHandler.bind(this)); }; const inlineDeleteHandler = function (e1) { e1.preventDefault(); const deleteButton = $(e1.target); const row = deleteButton.closest("." + options.formCssClass); const inlineGroup = row.closest(".inline-group"); // Remove the parent form containing this button, // and also remove the relevant row with non-field errors: const prevRow = row.prev(); if (prevRow.length && prevRow.hasClass("row-form-errors")) { prevRow.remove(); } // !CHANGED from original if (deleteButton.parent().parent().parent().parent().is("tbody")) { row.parent().remove(); } else { row.remove(); } nextIndex -= 1; // Pass the deleted form to the post-delete callback, if provided. if (options.removed) { options.removed(row); } document.dispatchEvent( new CustomEvent("formset:removed", { detail: { formsetName: options.prefix, }, }) ); // Update the TOTAL_FORMS form count. const forms = $("." + options.formCssClass); $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); // Show add button again once below maximum number. if (maxForms.val() === "" || maxForms.val() - forms.length > 0) { addButton.parent().show(); } // Hide the remove buttons if at min_num. toggleDeleteButtonVisibility(inlineGroup); // Also, update names and ids for all remaining form controls so // they remain in sequence: let i, formCount; const updateElementCallback = function () { updateElementIndex(this, options.prefix, i); }; for (i = 0, formCount = forms.length; i < formCount; i++) { updateElementIndex($(forms).get(i), options.prefix, i); $(forms.get(i)).find("*").each(updateElementCallback); } }; const toggleDeleteButtonVisibility = function (inlineGroup) { if (minForms.val() !== "" && minForms.val() - totalForms.val() >= 0) { inlineGroup.find(".inline-deletelink").hide(); } else { inlineGroup.find(".inline-deletelink").show(); } }; // !CHANGED from original. Business logic for tabular inlines is different. if ($this.parent().is("tbody")) { $this .parent() .parent() .find("tr.form-row") .each(function (i) { $(this) .not("." + options.emptyCssClass) .addClass(options.formCssClass); }); } else { $this.each(function (i) { $(this) .not("." + options.emptyCssClass) .addClass(options.formCssClass); }); } // Create the delete buttons for all unsaved inlines: // !CHANGED from original, added parent() and used find() instead of filter() $this .parent() .parent() .find( "." + options.formCssClass + ":not(.has_original):not(." + options.emptyCssClass + ")" ) .each(function () { addInlineDeleteButton($(this)); }); toggleDeleteButtonVisibility($this); // Create the add button, initially hidden. addButton = options.addButton; addInlineAddButton(); // Show the add button if allowed to add more items. // Note that max_num = None translates to a blank string. const showAddButton = maxForms.val() === "" || maxForms.val() - totalForms.val() > 0; if ($this.length && showAddButton) { addButton.parent().show(); } else { addButton.parent().hide(); } return this; }; /* Setup plugin defaults */ $.fn.formset.defaults = { prefix: "form", // The form prefix for your django formset addText: "add another", // Text for the add link deleteText: "remove", // Text for the delete link addCssClass: "add-row", // CSS class applied to the add link deleteCssClass: "delete-row", // CSS class applied to the delete link emptyCssClass: "empty-row", // CSS class applied to the empty row formCssClass: "dynamic-form", // CSS class applied to each form in a formset added: null, // Function called each time a new form is added removed: null, // Function called each time a form is deleted addButton: null, // Existing add button to use }; // Tabular inlines --------------------------------------------------------- $.fn.tabularFormset = function (selector, options, callback = null) { const $rows = $(this); const reinitDateTimeShortCuts = function () { // Reinitialize the calendar and clock widgets by force if (typeof DateTimeShortcuts !== "undefined") { $(".datetimeshortcuts").remove(); DateTimeShortcuts.init(); } }; const updateSelectFilter = function () { // If any SelectFilter widgets are a part of the new form, // instantiate a new SelectFilter instance for it. if (typeof SelectFilter !== "undefined") { $(".selectfilter").each(function (index, value) { SelectFilter.init(value.id, this.dataset.fieldName, false); }); $(".selectfilterstacked").each(function (index, value) { SelectFilter.init(value.id, this.dataset.fieldName, true); }); } }; const initPrepopulatedFields = function (row) { row.find(".prepopulated_field").each(function () { const field = $(this), input = field.find("input, select, textarea"), dependency_list = input.data("dependency_list") || [], dependencies = []; $.each(dependency_list, function (i, field_name) { dependencies.push( "#" + row .find(".field-" + field_name) .find("input, select, textarea") .attr("id") ); }); if (dependencies.length) { input.prepopulate(dependencies, input.attr("maxlength")); } }); }; $rows.formset({ prefix: options.prefix, addText: options.addText, formCssClass: "dynamic-" + options.prefix, deleteCssClass: "inline-deletelink", deleteText: options.deleteText, emptyCssClass: "empty-form", added: function (row) { initPrepopulatedFields(row); reinitDateTimeShortCuts(); updateSelectFilter(); }, addButton: options.addButton, }); if (typeof callback === "function") { callback(); } return $rows; }; // Stacked inlines --------------------------------------------------------- $.fn.stackedFormset = function (selector, options, callback = null) { const $rows = $(this); const updateInlineLabel = function (row) { $(selector) .find(".inline_label") .each(function (i) { const count = i + 1; $(this).html( $(this) .html() .replace(/(#\d+)/g, "#" + count) ); }); }; const reinitDateTimeShortCuts = function () { // Reinitialize the calendar and clock widgets by force, yuck. if (typeof DateTimeShortcuts !== "undefined") { $(".datetimeshortcuts").remove(); DateTimeShortcuts.init(); } }; const updateSelectFilter = function () { // If any SelectFilter widgets were added, instantiate a new instance. if (typeof SelectFilter !== "undefined") { $(".selectfilter").each(function (index, value) { SelectFilter.init(value.id, this.dataset.fieldName, false); }); $(".selectfilterstacked").each(function (index, value) { SelectFilter.init(value.id, this.dataset.fieldName, true); }); } }; const initPrepopulatedFields = function (row) { row.find(".prepopulated_field").each(function () { const field = $(this), input = field.find("input, select, textarea"), dependency_list = input.data("dependency_list") || [], dependencies = []; $.each(dependency_list, function (i, field_name) { // Dependency in a fieldset. let field_element = row.find(".form-row .field-" + field_name); // Dependency without a fieldset. if (!field_element.length) { field_element = row.find(".form-row.field-" + field_name); } dependencies.push( "#" + field_element.find("input, select, textarea").attr("id") ); }); if (dependencies.length) { input.prepopulate(dependencies, input.attr("maxlength")); } }); }; $rows.formset({ prefix: options.prefix, addText: options.addText, formCssClass: "dynamic-" + options.prefix, deleteCssClass: "inline-deletelink", deleteText: options.deleteText, emptyCssClass: "empty-form", removed: updateInlineLabel, added: function (row) { initPrepopulatedFields(row); reinitDateTimeShortCuts(); updateSelectFilter(); updateInlineLabel(row); }, addButton: options.addButton, }); if (typeof callback === "function") { callback(); } return $rows; }; $(window).on("htmx:afterSettle", function (event) { if (event.target.classList.contains("js-inline-admin-formset")) { initInlines($(event.target), function () { if (typeof DateTimeShortcuts !== "undefined") { $(".datetimeshortcuts").remove(); DateTimeShortcuts.init(); } $(event.target).find(".admin-autocomplete").djangoAdminSelect2(); }); } }); $(document).ready(function () { $(".js-inline-admin-formset").each(function () { initInlines(this); }); }); function initInlines(el, callback = null) { const data = $(el).data(), inlineOptions = data.inlineFormset; let selector; switch (data.inlineType) { case "stacked": selector = inlineOptions.name + "-group .inline-related"; $(selector).stackedFormset(selector, inlineOptions.options, callback); break; case "tabular": selector = inlineOptions.name + "-group .tabular.inline-related tbody:last > tr.form-row"; $(selector).tabularFormset(selector, inlineOptions.options, callback); break; } } }