From 7d895fe5f90d13caa970cdc8bed916fec3d24d0e Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Mon, 24 Apr 2023 02:00:38 +0200 Subject: [PATCH] =?UTF-8?q?Use=20HTMx=20to=20add=20product=20rows=20?= =?UTF-8?q?=E2=80=9Cinline=E2=80=9D=20in=20the=20invoice=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I actually find more comfortable to select the product from the list presented up until now, but this is mostly because i have very few products and the list is not too long, so the idea is that with JavaScript we will dynamically add an empty product row to the invoice and then use the name field to search the product by name. I have the feeling that i am doing something wrong because i ended up with a lot of HTMx attribute for what i feel is not that much work, but for now it will work. I have added the `Is` field to `InputField` in order to include the `id` attribute to the HTML element, because the HTMLAttributes are attached to the `input`, not the `div`, and i felt like this one should also be a custom element based on div, like all the others. These is not yet any keyboard control to select the search results. I am not happy with having the search of products in a different URL than the index, specially since they use the exact same SQL query and ProductFilter struct, but i did not know how else ask for a different representation without resorting to the more complicated MIME types. --- pkg/form.go | 1 + pkg/htmx.go | 9 +- pkg/invoices.go | 106 +++++++++++++++++----- pkg/products.go | 14 +-- pkg/router.go | 1 + web/static/bars.svg | 52 +++++++++++ web/static/numerus.css | 8 +- web/static/numerus.js | 44 +++++++++ web/template/form.gohtml | 23 +++-- web/template/invoices/edit.gohtml | 18 +++- web/template/invoices/new.gohtml | 18 +++- web/template/invoices/product-form.gohtml | 4 + web/template/products/search.gohtml | 11 +++ 13 files changed, 255 insertions(+), 54 deletions(-) create mode 100644 web/static/bars.svg create mode 100644 web/template/invoices/product-form.gohtml create mode 100644 web/template/products/search.gohtml diff --git a/pkg/form.go b/pkg/form.go index 81bde52..c205399 100644 --- a/pkg/form.go +++ b/pkg/form.go @@ -27,6 +27,7 @@ type InputField struct { Label string Type string Val string + Is string Required bool Attributes []template.HTMLAttr Errors []error diff --git a/pkg/htmx.go b/pkg/htmx.go index d11b831..6eb963d 100644 --- a/pkg/htmx.go +++ b/pkg/htmx.go @@ -6,10 +6,11 @@ import ( ) const ( - HxLocation = "HX-Location" - HxRefresh = "HX-Refresh" - HxRequest = "HX-Request" - HxTrigger = "HX-Trigger" + HxLocation = "HX-Location" + HxRefresh = "HX-Refresh" + HxRequest = "HX-Request" + HxTrigger = "HX-Trigger" + HxTriggerAfterSettle = "HX-Trigger-After-Settle" ) type HTMxLocation struct { diff --git a/pkg/invoices.go b/pkg/invoices.go index e5acce4..79263a7 100644 --- a/pkg/invoices.go +++ b/pkg/invoices.go @@ -222,7 +222,8 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para conn := getConn(r) company := mustGetCompany(r) slug := params[0].Value - if slug == "new" { + switch slug { + case "new": locale := getLocale(r) form := newInvoiceForm(r.Context(), conn, locale, company) if invoiceToDuplicate := r.URL.Query().Get("duplicate"); invoiceToDuplicate != "" { @@ -233,24 +234,42 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para form.Date.Val = time.Now().Format("2006-01-02") w.WriteHeader(http.StatusOK) mustRenderNewInvoiceForm(w, r, form) - return - } - - pdf := false - if strings.HasSuffix(slug, ".pdf") { - pdf = true - slug = slug[:len(slug)-len(".pdf")] - } - inv := mustGetInvoice(r.Context(), conn, company, slug) - if inv == nil { - http.NotFound(w, r) - return - } - if pdf { - w.Header().Set("Content-Type", "application/pdf") - mustWriteInvoicePdf(w, r, inv) - } else { - mustRenderMainTemplate(w, r, "invoices/view.gohtml", inv) + case "product-form": + company := mustGetCompany(r) + query := r.URL.Query() + index, _ := strconv.Atoi(query.Get("index")) + conn := getConn(r) + form := newInvoiceProductForm(index, company, getLocale(r), mustGetTaxOptions(r.Context(), conn, company)) + slug := query.Get("slug") + if len(slug) > 0 { + if !form.MustFillFromDatabase(r.Context(), conn, slug) { + http.NotFound(w, r) + return + } + quantity, _ := strconv.Atoi(query.Get("product.quantity." + strconv.Itoa(index))) + if quantity > 0 { + form.Quantity.Val = strconv.Itoa(quantity) + } + w.Header().Set(HxTriggerAfterSettle, "recompute") + } + mustRenderStandaloneTemplate(w, r, "invoices/product-form.gohtml", form) + default: + pdf := false + if strings.HasSuffix(slug, ".pdf") { + pdf = true + slug = slug[:len(slug)-len(".pdf")] + } + inv := mustGetInvoice(r.Context(), conn, company, slug) + if inv == nil { + http.NotFound(w, r) + return + } + if pdf { + w.Header().Set("Content-Type", "application/pdf") + mustWriteInvoicePdf(w, r, inv) + } else { + mustRenderMainTemplate(w, r, "invoices/view.gohtml", inv) + } } } @@ -643,7 +662,8 @@ func (form *invoiceForm) Update() { for n, product := range products { if product.Quantity.Val != "0" { if n != len(form.Products) { - product.Reindex(len(form.Products)) + product.Index = len(form.Products) + product.Rename() } form.Products = append(form.Products, product) } @@ -706,6 +726,7 @@ func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*Sel type invoiceProductForm struct { locale *Locale company *Company + Index int InvoiceProductId *InputField ProductId *InputField Name *InputField @@ -720,6 +741,7 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio form := &invoiceProductForm{ locale: locale, company: company, + Index: index, InvoiceProductId: &InputField{ Label: pgettext("input", "Id", locale), Type: "hidden", @@ -734,6 +756,15 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio Label: pgettext("input", "Name", locale), Type: "text", Required: true, + Is: "numerus-product-search", + Attributes: []template.HTMLAttr{ + `autocomplete="off"`, + `data-hx-trigger="keyup changed delay:200"`, + `data-hx-target="next .options"`, + `data-hx-indicator="closest div"`, + `data-hx-swap="innerHTML"`, + template.HTMLAttr(fmt.Sprintf(`data-hx-get="%v"`, companyURI(company, "/search/products"))), + }, }, Description: &InputField{ Label: pgettext("input", "Description", locale), @@ -771,12 +802,12 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio Options: taxOptions, }, } - form.Reindex(index) + form.Rename() return form } -func (form *invoiceProductForm) Reindex(index int) { - suffix := "." + strconv.Itoa(index) +func (form *invoiceProductForm) Rename() { + suffix := "." + strconv.Itoa(form.Index) form.InvoiceProductId.Name = "product.invoice_product_id" + suffix form.ProductId.Name = "product.id" + suffix form.Name.Name = "product.name" + suffix @@ -820,6 +851,35 @@ func (form *invoiceProductForm) Validate() bool { return validator.AllOK() } +func (form *invoiceProductForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { + return !notFoundErrorOrPanic(conn.QueryRow(ctx, ` + select product_id + , name + , description + , to_price(price, decimal_digits) + , 1 as quantity + , 0 as discount + , array_remove(array_agg(tax_id), null) + from product + join company using (company_id) + join currency using (currency_code) + left join product_tax using (product_id) + where product.slug = $1 + group by product_id + , name + , description + , price + , decimal_digits + `, slug).Scan( + form.ProductId, + form.Name, + form.Description, + form.Price, + form.Quantity, + form.Discount, + form.Tax)) +} + func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Params) { locale := getLocale(r) conn := getConn(r) diff --git a/pkg/products.go b/pkg/products.go index ce34053..f8d87c1 100644 --- a/pkg/products.go +++ b/pkg/products.go @@ -351,13 +351,13 @@ func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s } func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - var name string + filters := newProductFilterForm(getLocale(r)) query := r.URL.Query() - for k := range query { - vs := query[k] - if len(vs) > 0 { - name = vs[0] - } + index := query.Get("index") + filters.Name.Val = strings.TrimSpace(query.Get("product.name." + index)) + var products []*ProductEntry + if len(filters.Name.Val) > 0 { + products = mustCollectProductEntries(r.Context(), getConn(r), mustGetCompany(r), filters) } - w.Write([]byte(name)) + mustRenderStandaloneTemplate(w, r, "products/search.gohtml", products) } diff --git a/pkg/router.go b/pkg/router.go index c007c4d..3b3b45c 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -33,6 +33,7 @@ func NewRouter(db *Db) http.Handler { companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction) companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags) companyRouter.GET("/invoices/:slug/tags/edit", ServeEditInvoiceTags) + companyRouter.GET("/search/products", HandleProductSearch) companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { mustRenderMainTemplate(w, r, "dashboard.gohtml", nil) }) diff --git a/web/static/bars.svg b/web/static/bars.svg new file mode 100644 index 0000000..f2c59fd --- /dev/null +++ b/web/static/bars.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/static/numerus.css b/web/static/numerus.css index 308856e..6783f7b 100644 --- a/web/static/numerus.css +++ b/web/static/numerus.css @@ -614,7 +614,8 @@ main > nav { max-width: 35rem; } -[is="numerus-multiselect"] .tags, [is="numerus-tags"] .tags, [is="numerus-multiselect"] .options { +[is="numerus-multiselect"] .tags, [is="numerus-tags"] .tags, +[is="numerus-multiselect"] .options, [is="numerus-product-search"] .options { font-size: 1em; list-style: none; color: var(--numerus--text-color); @@ -682,6 +683,7 @@ main > nav { min-width: initial; } +[is="numerus-product-search"] .options, [is="numerus-multiselect"] .options { padding: 0; border-top: 0; @@ -691,6 +693,7 @@ main > nav { z-index: 10; } +[is="numerus-product-search"] .options li, [is="numerus-multiselect"] .options li { display: block; width: 100%; @@ -698,7 +701,8 @@ main > nav { padding: 1rem 2rem; } -[is="numerus-multiselect"] .options li:hover, [is="numerus-multiselect"] .options .highlight { +[is="numerus-product-search"] .options li:hover,[is="numerus-product-search"] .options .highlight, +[is="numerus-multiselect"] .options li:hover,[is="numerus-multiselect"] .options .highlight { background-color: var(--numerus--color--light-gray); } diff --git a/web/static/numerus.js b/web/static/numerus.js index e3cb1d9..761d13f 100644 --- a/web/static/numerus.js +++ b/web/static/numerus.js @@ -506,8 +506,52 @@ class Tags extends HTMLDivElement { } } +class ProductSearch extends HTMLDivElement { + constructor() { + super(); + this.initialized = false; + this.tags = []; + } + + connectedCallback() { + if (!this.isConnected) { + return; + } + if (!this.initialized) { + for (const child of this.children) { + switch (child.nodeName) { + case 'INPUT': + this.input = child; + break; + case 'LABEL': + this.label = child; + break; + } + } + if (this.label && this.input) { + this.init(); + } + } + } + + init() { + this.initialized = true; + + const list = document.createElement('ul'); + list.classList.add('options') + list.setAttribute('role', 'listbox') + this.append(list); + + const indicator = document.createElement('img'); + indicator.classList.add('htmx-indicator'); + indicator.src = '/static/bars.svg'; + this.append(indicator); + } +} + customElements.define('numerus-multiselect', Multiselect, {extends: 'div'}); customElements.define('numerus-tags', Tags, {extends: 'div'}); +customElements.define('numerus-product-search', ProductSearch, {extends: 'div'}); let savedTitle = ''; diff --git a/web/template/form.gohtml b/web/template/form.gohtml index a9c6de9..3f4445c 100644 --- a/web/template/form.gohtml +++ b/web/template/form.gohtml @@ -7,7 +7,7 @@ {{ define "input-field" -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}} -
+
{{ if eq .Type "textarea" }}