Use HTMx to delete and restore invoice products

It is better that way because it works without JavaScript; if HTMx is
not available, it will just use regulars forms.

The problem is that most of the submit buttons where using formaction
to send the request to a different action, and only one button was the
“real” action.  Since i could not pass the formaction to
invoice-product-form template, i have changed the “default” action to
the one with “ancillary” functions.

I have to use a different action to remove for each product because i
can not pass the index to the backend without JavaScript: it only
depends on the button click, that already has a name for the action.
Thus, in a way, i have “merged” the action and the index in a single
name.
This commit is contained in:
jordi fita mas 2023-05-29 00:01:11 +02:00
parent 07a28639f2
commit 8529da1615
4 changed files with 145 additions and 38 deletions

View File

@ -20,6 +20,8 @@ import (
"time" "time"
) )
const removedProductSuffix = ".removed"
type InvoiceEntry struct { type InvoiceEntry struct {
Slug string Slug string
Date time.Time Date time.Time
@ -553,16 +555,17 @@ func mustWriteInvoicesPdf(r *http.Request, slugs []string) []byte {
} }
type invoiceForm struct { type invoiceForm struct {
locale *Locale locale *Locale
company *Company company *Company
Number string Number string
InvoiceStatus *SelectField InvoiceStatus *SelectField
Customer *SelectField Customer *SelectField
Date *InputField Date *InputField
Notes *InputField Notes *InputField
PaymentMethod *SelectField PaymentMethod *SelectField
Tags *TagsField Tags *TagsField
Products []*invoiceProductForm Products []*invoiceProductForm
RemovedProduct *invoiceProductForm
} }
func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *invoiceForm { func newInvoiceForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *invoiceForm {
@ -665,6 +668,25 @@ func (form *invoiceForm) Update() {
} }
} }
func (form *invoiceForm) RemoveProduct(index int) {
products := form.Products
form.Products = nil
for n, product := range products {
if n == index {
form.RemovedProduct = product
} else {
if n != len(form.Products) {
product.Index = len(form.Products)
product.Rename()
}
form.Products = append(form.Products, product)
}
}
if form.RemovedProduct != nil {
form.RemovedProduct.RenameWithSuffix(removedProductSuffix)
}
}
const selectProductBySlug = ` const selectProductBySlug = `
select '' select ''
, product_id , product_id
@ -708,6 +730,23 @@ func (form *invoiceForm) mustAddProductsFromQuery(ctx context.Context, conn *Con
} }
} }
func (form *invoiceForm) InsertProduct(product *invoiceProductForm) {
replaced := false
for n, existing := range form.Products {
if existing.Quantity.Val == "" || existing.Quantity.Val == "0" {
product.Index = n
form.Products[n] = product
replaced = true
break
}
}
if !replaced {
product.Index = len(form.Products)
form.Products = append(form.Products, product)
}
product.Rename()
}
func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { func (form *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool {
var invoiceId int var invoiceId int
selectedInvoiceStatus := form.InvoiceStatus.Selected selectedInvoiceStatus := form.InvoiceStatus.Selected
@ -834,7 +873,9 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
} }
func (form *invoiceProductForm) Rename() { func (form *invoiceProductForm) Rename() {
suffix := "." + strconv.Itoa(form.Index) form.RenameWithSuffix("." + strconv.Itoa(form.Index))
}
func (form *invoiceProductForm) RenameWithSuffix(suffix string) {
form.InvoiceProductId.Name = "product.invoice_product_id" + suffix form.InvoiceProductId.Name = "product.invoice_product_id" + suffix
form.ProductId.Name = "product.id" + suffix form.ProductId.Name = "product.id" + suffix
form.Name.Name = "product.name" + suffix form.Name.Name = "product.name" + suffix
@ -1018,7 +1059,8 @@ func handleInvoiceAction(w http.ResponseWriter, r *http.Request, action string,
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
switch r.Form.Get("action") { actionField := r.Form.Get("action")
switch actionField {
case "update": case "update":
form.Update() form.Update()
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@ -1030,8 +1072,31 @@ func handleInvoiceAction(w http.ResponseWriter, r *http.Request, action string,
form.AddProducts(r.Context(), conn, r.Form["slug"]) form.AddProducts(r.Context(), conn, r.Form["slug"])
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
renderForm(w, r, form) renderForm(w, r, form)
case "restore-product":
restoredProduct := newInvoiceProductForm(0, company, locale, mustGetTaxOptions(r.Context(), conn, company))
restoredProduct.RenameWithSuffix(removedProductSuffix)
if err := restoredProduct.Parse(r); err != nil {
panic(err)
}
form.InsertProduct(restoredProduct)
form.Update()
w.WriteHeader(http.StatusOK)
renderForm(w, r, form)
default: default:
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest) prefix := "remove-product."
if strings.HasPrefix(actionField, prefix) {
index, err := strconv.Atoi(actionField[len(prefix):])
if err != nil {
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
} else {
form.RemoveProduct(index)
form.Update()
w.WriteHeader(http.StatusOK)
renderForm(w, r, form)
}
} else {
http.Error(w, gettext("Invalid action", locale), http.StatusBadRequest)
}
} }
} }

View File

@ -145,13 +145,13 @@
{{ define "invoice-product-form" -}} {{ define "invoice-product-form" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}}
<fieldset class="new-invoice-product" <fieldset class="new-invoice-product"
x-data="{removeProduct() { $el.remove(); document.getElementById('recompute-button').click(); }}"
data-hx-select="unset" data-hx-select="unset"
data-hx-vals='{"index": {{ .Index }}}' data-hx-vals='{"index": {{ .Index }}}'
data-hx-include="[name='product.quantity.{{ .Index }}']" data-hx-include="[name='product.quantity.{{ .Index }}']"
> >
<button x-cloak type="button" class="icon delete-product" <button type="submit" class="icon delete-product"
@click="removeProduct()" formnovalidate
name="action" value="remove-product.{{ .Index }}"
aria-label="{{( gettext "Delete product from invoice" )}}" aria-label="{{( gettext "Delete product from invoice" )}}"
><i class="ri-delete-back-2-line"></i></button> ><i class="ri-delete-back-2-line"></i></button>
{{ template "hidden-field" .InvoiceProductId }} {{ template "hidden-field" .InvoiceProductId }}

View File

@ -17,18 +17,38 @@
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editInvoicePage*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editInvoicePage*/ -}}
<section id="invoice-dialog-content" data-hx-target="main"> <section id="invoice-dialog-content" data-hx-target="main">
<h2>{{ printf (pgettext "Edit Invoice “%s”" "title") .Number }}</h2> <h2>{{ printf (pgettext "Edit Invoice “%s”" "title") .Number }}</h2>
<form method="POST" action="{{ companyURI "/invoices/" }}{{ .Slug }}" data-hx-boost="true"> <form method="POST" action="{{ companyURI "/invoices/" }}{{ .Slug }}/edit" data-hx-boost="true">
{{ csrfToken }} {{ csrfToken }}
{{ with .Form -}} {{ with .Form -}}
<div class="invoice-data"> {{ if .RemovedProduct -}}
{{ template "select-field" .Customer }} <div role="alert">
{{ template "hidden-field" .Date }} {{ with .RemovedProduct -}}
{{ template "tags-field" .Tags }} <p>{{printf (gettext "Product “%s” removed") .Name}}</p>
{{ template "select-field" .PaymentMethod }} <button type="submit"
{{ template "select-field" .InvoiceStatus }} formnovalidate
{{ template "input-field" .Notes }} name="action" value="restore-product"
</div> >{{( pgettext "Undo" "action" )}}</button>
{{ template "hidden-field" .InvoiceProductId }}
{{ template "hidden-field" .ProductId }}
{{ template "hidden-field" .Name }}
{{ template "hidden-field" .Price }}
{{ template "hidden-field" .Quantity }}
{{ template "hidden-field" .Discount }}
{{ template "hidden-field" .Description }}
{{ template "hidden-select-field" .Tax }}
{{- end }}
</div>
{{- end }}
<div class="invoice-data">
{{ template "select-field" .Customer }}
{{ template "hidden-field" .Date }}
{{ template "tags-field" .Tags }}
{{ template "select-field" .PaymentMethod }}
{{ template "select-field" .InvoiceStatus }}
{{ template "input-field" .Notes }}
</div>
{{- range $product := .Products }} {{- range $product := .Products }}
{{ template "invoice-product-form" . }} {{ template "invoice-product-form" . }}
@ -55,18 +75,19 @@
</table> </table>
<fieldset class="button-bar"> <fieldset class="button-bar">
<button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit" <button formnovalidate
name="action" value="select-products" name="action" value="select-products"
data-hx-get="{{ companyURI "/invoices/product-form" }}" data-hx-get="{{ companyURI "/invoices/product-form" }}"
data-hx-target="#invoice-summary" data-hx-swap="beforebegin" data-hx-target="#invoice-summary" data-hx-swap="beforebegin"
data-hx-select="unset" data-hx-select="unset"
data-hx-vals="js:{index: document.querySelectorAll('.new-invoice-product').length}" data-hx-vals="js:{index: document.querySelectorAll('.new-invoice-product').length}"
type="submit">{{( pgettext "Add products" "action" )}}</button> type="submit">{{( pgettext "Add products" "action" )}}</button>
<button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit" <button formnovalidate
id="recompute-button" id="recompute-button"
name="action" value="update" name="action" value="update"
type="submit">{{( pgettext "Update" "action" )}}</button> type="submit">{{( pgettext "Update" "action" )}}</button>
<button class="primary" name="_method" value="PUT" <button class="primary" name="_method" value="PUT"
formaction="{{ companyURI "/invoices" }}/{{ .Slug }}"
type="submit">{{( pgettext "Save" "action" )}}</button> type="submit">{{( pgettext "Save" "action" )}}</button>
</fieldset> </fieldset>

View File

@ -17,18 +17,38 @@
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newInvoicePage*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.newInvoicePage*/ -}}
<section id="invoice-dialog-content" data-hx-target="main"> <section id="invoice-dialog-content" data-hx-target="main">
<h2>{{(pgettext "New Invoice" "title")}}</h2> <h2>{{(pgettext "New Invoice" "title")}}</h2>
<form method="POST" action="{{ companyURI "/invoices" }}" data-hx-boost="true"> <form method="POST" action="{{ companyURI "/invoices/new" }}" data-hx-boost="true">
{{ csrfToken }} {{ csrfToken }}
{{ with .Form -}} {{ with .Form -}}
<div class="invoice-data"> {{ if .RemovedProduct -}}
{{ template "hidden-select-field" .InvoiceStatus }} <div role="alert">
{{ template "select-field" .Customer }} {{ with .RemovedProduct -}}
{{ template "input-field" .Date }} <p>{{printf (gettext "Product “%s” removed") .Name}}</p>
{{ template "tags-field" .Tags }} <button type="submit"
{{ template "select-field" .PaymentMethod }} formnovalidate
{{ template "input-field" .Notes }} name="action" value="restore-product"
</div> >{{( pgettext "Undo" "action" )}}</button>
{{ template "hidden-field" .InvoiceProductId }}
{{ template "hidden-field" .ProductId }}
{{ template "hidden-field" .Name }}
{{ template "hidden-field" .Price }}
{{ template "hidden-field" .Quantity }}
{{ template "hidden-field" .Discount }}
{{ template "hidden-field" .Description }}
{{ template "hidden-select-field" .Tax }}
{{- end }}
</div>
{{- end }}
<div class="invoice-data">
{{ template "hidden-select-field" .InvoiceStatus }}
{{ template "select-field" .Customer }}
{{ template "input-field" .Date }}
{{ template "tags-field" .Tags }}
{{ template "select-field" .PaymentMethod }}
{{ template "input-field" .Notes }}
</div>
{{- range $product := .Products }} {{- range $product := .Products }}
{{ template "invoice-product-form" . }} {{ template "invoice-product-form" . }}
{{- end }} {{- end }}
@ -54,18 +74,19 @@
</table> </table>
<fieldset class="button-bar"> <fieldset class="button-bar">
<button formnovalidate formaction="{{ companyURI "/invoices/new" }}" <button formnovalidate
name="action" value="select-products" name="action" value="select-products"
data-hx-get="{{ companyURI "/invoices/product-form" }}" data-hx-get="{{ companyURI "/invoices/product-form" }}"
data-hx-target="#invoice-summary" data-hx-swap="beforebegin" data-hx-target="#invoice-summary" data-hx-swap="beforebegin"
data-hx-select="unset" data-hx-select="unset"
data-hx-vals="js:{index: document.querySelectorAll('.new-invoice-product').length}" data-hx-vals="js:{index: document.querySelectorAll('.new-invoice-product').length}"
type="submit">{{( pgettext "Add products" "action" )}}</button> type="submit">{{( pgettext "Add products" "action" )}}</button>
<button formnovalidate formaction="{{ companyURI "/invoices/new" }}" <button formnovalidate
id="recompute-button" id="recompute-button"
name="action" value="update" name="action" value="update"
type="submit">{{( pgettext "Update" "action" )}}</button> type="submit">{{( pgettext "Update" "action" )}}</button>
<button class="primary" name="action" value="add" <button class="primary" name="action" value="add"
formaction="{{ companyURI "/invoices" }}"
type="submit">{{( pgettext "Save" "action" )}}</button> type="submit">{{( pgettext "Save" "action" )}}</button>
</fieldset> </fieldset>
</form> </form>