From 7bb3d064eed4dcba7505c882a61e97af51dba35c Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 7 Mar 2026 10:18:52 +0100 Subject: [PATCH 1/2] add dynamic-formset-index.ts --- .../bundled/core/dynamic-formset-index.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 core/static/bundled/core/dynamic-formset-index.ts 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..d503c55a --- /dev/null +++ b/core/static/bundled/core/dynamic-formset-index.ts @@ -0,0 +1,74 @@ +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. + */ + 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()); + } + } + }, + })); +}); From f17f17d8de74c07e24e7dc3f7b711e9cc1183f18 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 7 Mar 2026 16:21:18 +0100 Subject: [PATCH 2/2] use dynamic formset for product action formset --- .../bundled/core/dynamic-formset-index.ts | 3 + core/templates/core/base.jinja | 4 +- counter/forms.py | 3 +- counter/templates/counter/product_form.jinja | 81 ++++++++++++------- locale/fr/LC_MESSAGES/django.po | 8 ++ 5 files changed, 68 insertions(+), 31 deletions(-) diff --git a/core/static/bundled/core/dynamic-formset-index.ts b/core/static/bundled/core/dynamic-formset-index.ts index d503c55a..6b71e25f 100644 --- a/core/static/bundled/core/dynamic-formset-index.ts +++ b/core/static/bundled/core/dynamic-formset-index.ts @@ -21,6 +21,9 @@ document.addEventListener("alpine:init", () => { * - 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() { 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) %} +
+ {{ form.non_field_errors() }} +
+
+ {{ form.task.errors }} + {{ form.task.label_tag() }} + {{ form.task|add_attr("x-model=action") }} +
+
{{ form.trigger_at.as_field_group() }}
+
+
+ {{ form.counters.as_field_group() }} +
+ {%- if form.DELETE -%} +
+ {{ form.DELETE.as_field_group() }} +
+ {%- else -%} + + {%- endif -%} + {%- for field in form.hidden_fields() -%} + {{ field }} + {%- endfor -%} +
+
+{% 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 -%} -
- {{ action_form.non_field_errors() }} -
-
- {{ action_form.task.errors }} - {{ action_form.task.label_tag() }} - {{ action_form.task|add_attr("x-model=action") }} -
-
{{ action_form.trigger_at.as_field_group() }}
-
-
- {{ action_form.counters.as_field_group() }} -
- {%- if action_form.DELETE -%} -
- {{ action_form.DELETE.as_field_group() }} -
- {%- endif -%} - {%- for field in action_form.hidden_fields() -%} - {{ field }} +
+ {{ form.action_formset.management_form }} +
+ {%- for f in form.action_formset.forms -%} + {{ action_form(f) }} {%- endfor -%} -
- {%- if not loop.last -%} -
- {%- endif -%} - {%- endfor -%} -

+ + + + +

{% endblock %} \ No newline at end of file diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 8600040b..9275ce76 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -3757,6 +3757,10 @@ msgstr "" "votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " "aucune conséquence autre que le retrait de l'argent de votre compte." +#: counter/templates/counter/product_form.jinja +msgid "Remove this action" +msgstr "Retirer cette action" + #: counter/templates/counter/product_form.jinja #, python-format msgid "Edit product %(name)s" @@ -3784,6 +3788,10 @@ msgstr "" "Les actions automatiques vous permettent de planifier des modifications du " "produit à l'avance." +#: counter/templates/counter/product_form.jinja +msgid "Add action" +msgstr "Ajouter une action" + #: counter/templates/counter/product_list.jinja msgid "Product list" msgstr "Liste des produits"