Skip to content

Commit

Permalink
fix: Formset support (#69)
Browse files Browse the repository at this point in the history
* add formset examples

* add tests

* dont escape component-id, it break dynamic formsets
  • Loading branch information
AlexCLeduc authored Nov 27, 2024
1 parent 44d201a commit d6e7975
Show file tree
Hide file tree
Showing 7 changed files with 351 additions and 5 deletions.
2 changes: 1 addition & 1 deletion autocomplete/templates/autocomplete/textinput.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

onkeydown="return phac_aspc_autocomplete_keydown_handler(event)"
onkeyup="return phac_aspc_autocomplete_keyup_handler(event)"
onblur="return phac_aspc_autocomplete_blur_handler(event, '{{ component_id|escapejs }}', {{ multiselect|yesno:'false,true' }})"
onblur="return phac_aspc_autocomplete_blur_handler(event, '{{ component_id }}', {{ multiselect|yesno:'false,true' }})"
onfocus="return phac_aspc_autocomplete_focus_handler(event)"

hx-get="{% url 'autocomplete:items' ac_name=route_name %}"
Expand Down
5 changes: 2 additions & 3 deletions sample_app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,12 @@
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]"
integrity="sha384-e2no7T1BxIs3ngCTptBu4TjvRWF4bBjFW0pt7TpxOEkRJuvrjRt29znnYuoLTz9S"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
})
htmx.logAll();
</script>
</body>
</html>
197 changes: 197 additions & 0 deletions sample_app/templates/dynamic_formset_example.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
{% extends 'base.html' %}

{% load autocomplete %}
{% block content %}
<div class="container">


<div class="d-none" id="form-template">
<div class="formset-item">
<hr class="my-5"/>
{{ formset.empty_form.as_p }}
</div>
</div>


<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
<div id="formlist">
{% for form in formset %}
<div class="formset-item">
<hr class="my-5"/>
{{ form.as_p }}
</div>
{% endfor %}
</div>
<div class="my-5">
<button id="add-another" type="button" class="add-row btn btn-primary">Add another form</button>
</div>
<div>
<input type="submit" value="Submit">
</div>
</form>
</div>


<script>
class DynamicFormsetManager {
constructor({ formsetPrefix, formListSelector, templateContainerSelector, addButtonSelector }) {
this.formsetPrefix = formsetPrefix;
// this.formClass = formClass;
this.formListSelector = formListSelector;
this.templateContainerSelector = templateContainerSelector;
this.addButtonSelector = addButtonSelector;


// trigger validation checks
this.getAddButton();
this.getTemplateContainer();

}

activate() {
//only public method
this.getAddButton().addEventListener('click', this.addForm.bind(this));
}

// "private" methods:
getTemplateContainer() {
const node = document.querySelector(this.templateContainerSelector);
if (!node) {
throw new Error(`No element found with selector ${this.templateContainerSelector} (templateContainerSelector)`);
}
if (node.children.length !== 1) {
throw new Error(`The templateContainer should contain a single child node, which is the template`);
}
return node;
}
getFormTemplateNode() {
return this.getTemplateContainer().children[0];
}

getAddButton() {
const node = document.querySelector(this.addButtonSelector)
if (!node) {
throw new Error(`No element found with selector ${this.addButtonSelector} (addButtonSelector)`);
}
return node;
}

setTotalFormsCount(numForms) {
return document.querySelector(`#id_${this.formsetPrefix}-TOTAL_FORMS`).setAttribute('value', `${numForms}`);
}

getFormNodes() {
//override in case your have extra nodes that don't correspond to forms

// these aren't <forms>, but rather the container that is duplicated for each form and contains a single form's inputs
// return Array.from(document.querySelectorAll(`.${this.formClass}`)).filter(node => node !== this.getFormTemplateNode());
return Array.from(this.getFormListContainer().children);
}

getFormListContainer() {
// return this.getFormNodes()[0].parentNode;
const node = document.querySelector(this.formListSelector);
if (!node) {
throw new Error(`No element found with selector ${this.formListSelector} (formListSelector)`);
}
return node;
}

getNewHtmlForForm(newFormIndex) {
const { formsetPrefix } = this;
const formTemplateNode = this.getFormTemplateNode();
const fieldFormRegex = RegExp(`${formsetPrefix}-__prefix__-`, 'g')
const fragmentsFormRegex = RegExp(`fragment-${formsetPrefix}-__prefix__`, 'g')

const newFormHtml = formTemplateNode.outerHTML
.replace(fieldFormRegex, `${formsetPrefix}-${newFormIndex}-`)
// note the fragment IDs don't have a '-' suffix
.replace(fragmentsFormRegex, `fragment-${formsetPrefix}-${newFormIndex}`);


return newFormHtml
}

createNewFormNode(formIndex) {
// override in case, e.g. index needs to be used in text


// It would be easier to write the parent's innerHTML
// but that would risk losing event listeners, potentially the addButton!
// Also, since we don't know what nodeType the form is
// we use a dummy parent rather than createElement(unknownNodeType)
const dummyContainer = document.createElement('div')
dummyContainer.innerHTML = this.getNewHtmlForForm(formIndex)
const newForm = dummyContainer.children[0]

return newForm;
}

addForm(e) {
e.preventDefault();

const previousNumForms = this.getFormNodes().length
const newFormIndex = previousNumForms; // indexing starts at 0
const newNumForms = previousNumForms + 1; //total forms after addition
const formListContainer = this.getFormListContainer()

const newFormNode = this.createNewFormNode(newFormIndex);

formListContainer.appendChild(newFormNode)

this.setTotalFormsCount(newNumForms)

// these lines below are the only autocomplete related code

// htmx by default processes nodes it adds
// but since JS is adding these forms, we need to manually process them
htmx.process(newFormNode)
this.manuallyExecuteScripts(newFormNode)
}

manuallyExecuteScripts(container){
/*
when you insert new scripts via innerHTML, they're not executed automatically
so we have to recreate these script tags and insert them via the DOM api
*/
// Find all <script> tags in the newly inserted content
const scripts = container.querySelectorAll('script');

// Reinsert each <script> tag
scripts.forEach(originalScript => {
parent = originalScript.parentNode
const newScript = document.createElement('script');
// Copy the script content or src attribute
if (originalScript.src) {
newScript.src = originalScript.src; // External script
} else {
newScript.textContent = originalScript.textContent; // Inline script
}
// Copy any attributes (e.g., type, async, etc.)
Array.from(originalScript.attributes).forEach(attr =>
newScript.setAttribute(attr.name, attr.value)
);

// Append the new script to the document
parent.appendChild(newScript);

});

}

}
</script>
<script>
const formsetManager = new DynamicFormsetManager({
formsetPrefix: "teams",
//formClass: "formset-item",
formListSelector: "#formlist",
templateContainerSelector: '#form-template',
addButtonSelector: '#add-another',
})
formsetManager.activate();
</script>

{% endblock %}
18 changes: 18 additions & 0 deletions sample_app/templates/static_formset_example.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% extends 'base.html' %}

{% load autocomplete %}
{% block content %}
<div class="container">
<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<hr class="my-5"/>
{{ form.as_p }}
{% endfor %}
<div>
<input type="submit" value="Submit">
</div>
</form>
</div>
{% endblock %}
14 changes: 13 additions & 1 deletion sample_app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,19 @@
views.example_with_prefix,
name="edit_team_w_prefix",
),
path("teams/<int:team_id>/edit3/", views.example_with_model, name="edit_team3"),
path(
"teams/<int:team_id>/edit3/", views.example_with_model, name="edit_team_w_model"
),
path(
"static_formset_example/",
views.static_formset_example,
name="static_formset_example",
),
path(
"dynamic_formset_example/",
views.dynamic_formset_example,
name="dynamic_formset_example",
),
path("ac/", autocomplete_urls),
path("app/__debug__/", include("debug_toolbar.urls")),
]
61 changes: 61 additions & 0 deletions sample_app/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django import forms
from django.forms.models import modelformset_factory
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.template import loader
Expand Down Expand Up @@ -149,6 +150,21 @@ class Meta:
}


class SimplestPersonFormBothFields(forms.ModelForm):
class Meta:
model = Team
fields = ["team_lead", "members"]
widgets = {
"team_lead": AutocompleteWidget(
ac_class=CustomPersonAutocomplete3,
),
"members": AutocompleteWidget(
ac_class=CustomPersonAutocomplete3,
options={"multiselect": True},
),
}


def example_with_model(request, team_id=None):
team = Team.objects.get(id=team_id)

Expand All @@ -159,3 +175,48 @@ def example_with_model(request, team_id=None):
return HttpResponseRedirect(request.path)

return render(request, "edit_team.html", {"form": form})


def static_formset_example(request):
first_3_teams_ids = {t.pk for t in Team.objects.all()[:3]}
qs = Team.objects.filter(pk__in=first_3_teams_ids)
formset_factory = modelformset_factory(
Team, form=SimplestPersonFormBothFields, extra=0
)

if request.method == "POST":
formset = formset_factory(request.POST, queryset=qs)
else:
formset = formset_factory(queryset=qs)

if request.POST:
if formset.is_valid():
formset.save()
return HttpResponseRedirect(request.path)
else:
print(formset.errors)

return render(request, "static_formset_example.html", {"formset": formset})


def dynamic_formset_example(request):
# first_3_teams_ids = {t.pk for t in Team.objects.all()}
# qs = Team.objects.filter(pk__in=first_3_teams_ids)
qs = Team.objects.all()
formset_factory = modelformset_factory(
Team, form=SimplestPersonFormBothFields, extra=0
)

if request.method == "POST":
formset = formset_factory(request.POST, queryset=qs, prefix="teams")
else:
formset = formset_factory(queryset=qs, prefix="teams")

if request.POST:
if formset.is_valid():
formset.save()
return HttpResponseRedirect(request.path)
else:
print(formset.errors)

return render(request, "dynamic_formset_example.html", {"formset": formset})
Loading

0 comments on commit d6e7975

Please sign in to comment.