Динамическое добавление/удаление полей форм в Django

Задача стояла следующая:
есть список сущностей (к примеру, упражнений), каждая из которых в форме выводится в виде нескольких полей ввода (к примеру, сеты и название упражнения), хочется иметь возможность яваскриптом добавлять/удалять упражнения, менять их положение.
Для этого были использованы формсеты (django.forms.formsets) и jquery на клиенте.

В теории всё так: в шаблон передаём формы из формсета (подводный камень тут — для правильной валидации нужно в шаблоне внутри формы нужно не забыть вписать скрытые инпуты, отвечающие за количество форм формсета на странице, для этого пишем в шаблоне {{ exercisesFormset.management_form }}).
В шаблоне у каждого упражнения присутствуют контролы для удаления/добавления и перемещения упражнений, после загрузки страницы ненужные контролы у каждого упражнения скрываются, а после, к примеру, добавления нового упражнения контролы перерисовываются.
Для удаления все поля ввода упражнения очищаются, после чего скрываются.

Это была теория, теперь практика. Ниже гольный код с комментариями.

Во вьюхе нужно добавить следующее:

from django.forms.formsets import formset_factory
...
class NameForm( forms.ModelForm ):
	name = forms.CharField( max_length = 250 )
	description = forms.CharField( widget = forms.Textarea, required = False )

class ExerciseNameForm( forms.Form ):
	name = forms.ModelChoiceField( Exercise.objects.all() )
	sets = forms.IntegerField( min_value = 1, max_value = 200 )
...
@login_required
def add( request, id ):
	user = request.user
	other = Other()
	if request.method == "POST":
		nameForm = NameForm( request.POST, instance = other )
		exercisesFormsetClass = formset_factory( ExerciseNameForm )
		exercisesFormset = exercisesFormsetClass( request.POST, prefix = "exercises" )
		if nameForm.is_valid() and exercisesFormset.is_valid():
			nameData = nameForm.cleaned_data
			other.name = nameData["name"]
			other.description = nameData["description"]
			other.save()
			for position, exerciseForm in enumerate( exercisesFormset.forms, start = 1 ):
				exerciseData = exerciseForm.cleaned_data
				exercise = Exercise()
				exercise.exercise = exerciseData["name"]
				exercise.other = other
				exercise.sets = exerciseData["sets"]
				exercise.position = position
				exercise.save()
			return HttpResponseRedirect( "/other/%s/" % other.id )
		hide_exercises = request.POST["hide_exercises"]
	else:
		exercisesFormsetClass = formset_factory( ExerciseNameForm, extra = 1 )
		nameForm = NameForm( instance = other )
		exercisesFormset = exercisesFormsetClass( prefix = "exercises" )
		hide_exercises = ""
	return render_to_response( "add.html", { "nameForm": nameForm, "exercisesFormset": exercisesFormset, "hide_exercises": hide_exercises }, context_instance = RequestContext( request ) )

В шаблоне следующий код:

<script>
$(document).ready(function() {
	fixTotalFormsNumber();
	initiateControls();
	hideDeletedExercises(); 
});
</script>
{% block content %}
	<form action="" method="POST">
		<div class="parent">
			{% csrf_token %}
			<ul>
				{{ nameForm.as_ul }}
				{{ exercisesFormset.management_form }}
			</ul>
			{% for exerciseForm in exercisesFormset.forms %}
				<ul class="exercise-form-data">
					{{ exerciseForm.as_ul }}
					<li class="controls">
						<span class="up clickable">вверх</span>
						<span class="down clickable">вниз</span>
						<span class="delete clickable">удалить</span>
						<span class="add clickable">добавить</span>
					</li>
				</ul>
			{% endfor %}
		</div>
		<input type="hidden" name="hide_exercises" value="{{ hide_exercises }}" />
		<input type="submit" name="Добавить" />
	</form>
{% endblock %}

В шаблоне также грузится яваскрипт:

/**
* При обновлении страницы лишние элементы со страницы пропадут, а количество форм для обработки на стороне сервера останется (фф запомнит)
*/
function fixTotalFormsNumber()
{
	var inputNamePrefix = "exercises-",
		$parent = $( ".parent" );
	var count = $parent.find( "ul.exercise-form-data" ).length;
	$parent.find( "input[name=" + inputNamePrefix + "TOTAL_FORMS]" ).val( count );
}

/**
* Инициируем контролы
*/
function initiateControls()
{
	var $parent = $( ".parent" );
	$parent.find( ".controls .add" ).live( "click", function( Event ) { controlClicked( Event, "add" ) } );
	$parent.find( ".controls .delete" ).live( "click", function( Event ) { controlClicked( Event, "delete" ) } );
	$parent.find( ".controls .up" ).live( "click", function( Event ) { controlClicked( Event, "up" ) } );
	$parent.find( ".controls .down" ).live( "click", function( Event ) { controlClicked( Event, "down" ) } );
	updateControlsVisibility();
}

/**
* Скрывает "удалённые" упражнения при перезагрузке страницы
*/
function hideDeletedExercises()
{
	var $hideExercisesInput = $( "input[name=hide_exercises]" );
	var hideByIndexes = $hideExercisesInput.val().split( "," );
	if ( hideByIndexes.length )
	{
		var $rows = $( ".exercise-form-data" );
		for ( var rowNum = 0, count = $rows.length; rowNum < count; rowNum ++ )
		{
			var $row = $( $rows[rowNum] );
			if ( $.inArray( rowNum + "", hideByIndexes ) > -1 ) {
				$row.hide();
			}
		}
	}
}

function updateControlsVisibility()
{
	var $rows = $( ".exercise-form-data:visible" );
	for ( var rowNum = 0, count = $rows.length; rowNum < count; rowNum ++ )
	{
		var $row = $( $rows[rowNum] );
		if ( rowNum == 0 ) {
			$row.find( ".up" ).hide();
		}
		else {
			$row.find( ".up" ).show();
		}
		if ( rowNum == ( count - 1 ) )
		{
			$row.find( ".down" ).hide();
			$row.find( ".add" ).show();
		}
		else
		{
			$row.find( ".down" ).show();
			$row.find( ".add" ).hide();
		}
		if ( count == 1 ) {
			$row.find( ".delete" ).hide();
		}
		else {
			$row.find( ".delete" ).show();
		}
	}
}

/**
* При клике на контрол
* @param Event
* @param string ControlType
*/
function controlClicked( Event, ControlType )
{
	if ( ControlType == "add" )
	{
		addNewExerciseInputs();
		updateControlsVisibility();
	}
	else
	{
		var $el = $( Event.target ),
			inputSelectors = [ "select[name$=name]", "input[name$=sets]" ],
			rowSelector = ".exercise-form-data";
		var $currentRow = $el.parents( rowSelector );
		if ( ControlType == "delete" )
		{
			deleteExercise( $currentRow, inputSelectors );
			updateControlsVisibility();
		}
		else
		{
			rowSelector += ":visible"
			var $changeToRow;
			if ( ControlType == "up" ) {
				$changeToRow = $currentRow.prevAll( rowSelector );
			}
			else {
				$changeToRow = $currentRow.nextAll( rowSelector )
			}
			if ( $changeToRow.length ) {
				$changeToRow = $changeToRow.eq( 0 );
			}
			changeExerciseInputValues( $currentRow, $changeToRow, inputSelectors );
		}
	}

/**
 * Добавляем поля ввода для ещё одного упражнения
 */
	function addNewExerciseInputs()
	{
		var inputNamePrefix = "exercises-",
			$parent = $( ".parent" ),
		// в качестве шаболна используем последний ul для ввода упражнения
			$sourceExercise = $parent.find( "ul.exercise-form-data:last" );
	// если нашли ul и он правильный
		if ( $sourceExercise.length && $sourceExercise.find( "input[name^=" + inputNamePrefix + "]" ).length )
		{
		// для вычисления номера в шаблоне
			var re1 = new RegExp( inputNamePrefix + "(\\d*)-" );
		// для замены номеров
			var re2 = new RegExp( inputNamePrefix + "\\d*(-[^'\"]*)", "g" );
		// копируем html шаблона
			var newExerciseHtml = $sourceExercise.html();
		// получаем номер
			var sourceRowNum = parseInt( re1.exec( newExerciseHtml )[1] );
			var newRowNum = sourceRowNum + 1;
		// в html нового ul вписываем правильный номер
			newExerciseHtml = newExerciseHtml.replace( re2, inputNamePrefix + newRowNum + "$1" );
			$parent.append( "<ul class='exercise-form-data'>" + newExerciseHtml + "</ul>" );

		// увеличиваем количество форм для обработки на стороне сервера
			var $managerNums = $parent.find( "input[name=" + inputNamePrefix + "TOTAL_FORMS]" );
			$managerNums.val( parseInt( $managerNums.val() ) + 1 );
		}
	}

/**
 * "Удаляем" упражнение
 */
	function deleteExercise( $Row, InputSelectors )
	{
		if ( $Row.length )
		{
		// с пустым параметром просто сбросит все значения
			exerciseData( $Row, InputSelectors, "set" );
			$Row.hide();
			// для скрывания этого упражнения при перезагрузке страницы
				var $hideExercisesInput = $( "input[name=hide_exercises]" );
				$hideExercisesInput.val( $hideExercisesInput.val() + $Row.index( "ul.exercise-form-data" ) + "," );

		}
	}

	/**
	 * Меняем два упражнения местами
	 * @param jquery $Row1
	 * @param jquery $Row2
	 * @param Array InputSelectors
	 */
	function changeExerciseInputValues( $Row1, $Row2, InputSelectors )
	{
		if ( $Row1.length && $Row2.length )
		{
			var row1Values = exerciseData( $Row1, InputSelectors, "get" ),
				row2Values = exerciseData( $Row2, InputSelectors, "get" );
			 exerciseData( $Row1, InputSelectors, "set", row2Values );
			 exerciseData( $Row2, InputSelectors, "set", row1Values );
		}
	}

/**
 * Геттер/сеттер всех инпутов упражнения (так сложно - чтобы сеттер всегда понимал формат геттера)
 * @param jquery $Row
 * @param Array InputSelectors
 * @param string Type - get/set
 * @param Array Values - для сеттера массив значений
 */
	function exerciseData( $Row, InputSelectors, Type, Values )
	{
		Type = Type == "set" ? "set" : "get";
		Values = Values ? Values : [];
	// Пробегаемся по всем нужным элементам ввода
	// если элемент элемент типа select, то получаем/заполняем выбранный option, для инпутов - то же самое с value
		for ( var i = 0, count = InputSelectors.length; i < count; i ++ )
		{
			var inputSelector = InputSelectors[i];
			var $input = $Row.find( inputSelector );
			if ( $input.is( "select" ) )
			{
				if ( Type == "get" ) {
					Values[i] = $input[0].selectedIndex;
				}
				else {
					$input[0].selectedIndex = i in Values ? Values[i] : 0;
				}
			}
			else
			{
				if ( Type == "get" ) {
					Values[i] = $input.val();
				}
				else {
					$input.val( i in Values ? Values[i] : "" );
				}
			}
		}
		return Values;
	}
}

Альтернативы:
http://stackoverflow.com/questions/801354/django-equivalent-of-phps-form-value-array-associative-array.
http://eikke.com/django-generic-ajax-form-validation/ - для валидации формы ajax'ом.

LEAVE A COMMENT