diff --git a/core/static/bundled/core/dynamic-formset-index.ts b/core/static/bundled/core/dynamic-formset-index.ts
new file mode 100644
index 00000000..6b71e25f
--- /dev/null
+++ b/core/static/bundled/core/dynamic-formset-index.ts
@@ -0,0 +1,77 @@
+interface Config {
+ /**
+ * The prefix of the formset, in case it has been changed.
+ * See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
+ */
+ prefix?: string;
+}
+
+// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
+type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
+
+document.addEventListener("alpine:init", () => {
+ /**
+ * Alpine data element to allow the dynamic addition of forms to a formset.
+ *
+ * To use this, you need :
+ * - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
+ * - a template containing the empty form
+ * (that you can obtain jinja-side with `{{ formset.empty_form }}`),
+ * noted by `x-ref="formTemplate"`
+ * - a button with `@click="addForm"`
+ * - you may also have one or more buttons with `@click="removeForm(element)"`,
+ * where `element` is the HTML element containing the form.
+ *
+ * For an example of how this is used, you can have a look to
+ * `counter/templates/counter/product_form.jinja`
+ */
+ Alpine.data("dynamicFormSet", (config?: Config) => ({
+ init() {
+ this.formContainer = this.$refs.formContainer as HTMLElement;
+ this.nbForms = this.formContainer.children.length as number;
+ this.template = this.$refs.formTemplate as HTMLTemplateElement;
+ const prefix = config?.prefix ?? "form";
+ this.$root
+ .querySelector(`#id_${prefix}-TOTAL_FORMS`)
+ .setAttribute(":value", "nbForms");
+ },
+
+ addForm() {
+ this.formContainer.appendChild(document.importNode(this.template.content, true));
+ const newForm = this.formContainer.lastElementChild;
+ const inputs: NodeListOf = newForm.querySelectorAll(
+ "input, select, textarea",
+ );
+ for (const el of inputs) {
+ el.name = el.name.replace("__prefix__", this.nbForms.toString());
+ el.id = el.id.replace("__prefix__", this.nbForms.toString());
+ }
+ const labels: NodeListOf = newForm.querySelectorAll("label");
+ for (const el of labels) {
+ el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString());
+ }
+ inputs[0].focus();
+ this.nbForms += 1;
+ },
+
+ removeForm(container: HTMLDivElement) {
+ container.remove();
+ this.nbForms -= 1;
+ // adjust the id of remaining forms
+ for (let i = 0; i < this.nbForms; i++) {
+ const form: HTMLDivElement = this.formContainer.children[i];
+ const inputs: NodeListOf = form.querySelectorAll(
+ "input, select, textarea",
+ );
+ for (const el of inputs) {
+ el.name = el.name.replace(/\d+/, i.toString());
+ el.id = el.id.replace(/\d+/, i.toString());
+ }
+ const labels: NodeListOf = form.querySelectorAll("label");
+ for (const el of labels) {
+ el.htmlFor = el.htmlFor.replace(/\d+/, i.toString());
+ }
+ }
+ },
+ }));
+});
diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja
index 34a8040b..025dacdf 100644
--- a/core/templates/core/base.jinja
+++ b/core/templates/core/base.jinja
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/counter/forms.py b/counter/forms.py
index 1d9fa8a0..33bfd51a 100644
--- a/counter/forms.py
+++ b/counter/forms.py
@@ -291,7 +291,8 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
absolute_max=None,
can_delete=True,
can_delete_extra=False,
- extra=2,
+ extra=0,
+ min_num=1,
)
diff --git a/counter/templates/counter/product_form.jinja b/counter/templates/counter/product_form.jinja
index f3d3728c..ffd751c0 100644
--- a/counter/templates/counter/product_form.jinja
+++ b/counter/templates/counter/product_form.jinja
@@ -1,5 +1,44 @@
{% extends "core/base.jinja" %}
+{% block additional_js %}
+
+{% endblock %}
+
+
+{% macro action_form(form) %}
+
+{% endmacro %}
+
+
{% block content %}
{% if object %}
{% trans name=object %}Edit product {{ name }}{% endtrans %}
@@ -25,34 +64,20 @@
- {{ form.action_formset.management_form }}
- {%- for action_form in form.action_formset.forms -%}
-