Use HTMx to add product rows “inline” in the invoice form

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.
This commit is contained in:
jordi fita mas 2023-04-24 02:00:38 +02:00
parent 2ced61d304
commit 7d895fe5f9
13 changed files with 255 additions and 54 deletions

View File

@ -27,6 +27,7 @@ type InputField struct {
Label string Label string
Type string Type string
Val string Val string
Is string
Required bool Required bool
Attributes []template.HTMLAttr Attributes []template.HTMLAttr
Errors []error Errors []error

View File

@ -6,10 +6,11 @@ import (
) )
const ( const (
HxLocation = "HX-Location" HxLocation = "HX-Location"
HxRefresh = "HX-Refresh" HxRefresh = "HX-Refresh"
HxRequest = "HX-Request" HxRequest = "HX-Request"
HxTrigger = "HX-Trigger" HxTrigger = "HX-Trigger"
HxTriggerAfterSettle = "HX-Trigger-After-Settle"
) )
type HTMxLocation struct { type HTMxLocation struct {

View File

@ -222,7 +222,8 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := mustGetCompany(r)
slug := params[0].Value slug := params[0].Value
if slug == "new" { switch slug {
case "new":
locale := getLocale(r) locale := getLocale(r)
form := newInvoiceForm(r.Context(), conn, locale, company) form := newInvoiceForm(r.Context(), conn, locale, company)
if invoiceToDuplicate := r.URL.Query().Get("duplicate"); invoiceToDuplicate != "" { 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") form.Date.Val = time.Now().Format("2006-01-02")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
mustRenderNewInvoiceForm(w, r, form) mustRenderNewInvoiceForm(w, r, form)
return case "product-form":
} company := mustGetCompany(r)
query := r.URL.Query()
pdf := false index, _ := strconv.Atoi(query.Get("index"))
if strings.HasSuffix(slug, ".pdf") { conn := getConn(r)
pdf = true form := newInvoiceProductForm(index, company, getLocale(r), mustGetTaxOptions(r.Context(), conn, company))
slug = slug[:len(slug)-len(".pdf")] slug := query.Get("slug")
} if len(slug) > 0 {
inv := mustGetInvoice(r.Context(), conn, company, slug) if !form.MustFillFromDatabase(r.Context(), conn, slug) {
if inv == nil { http.NotFound(w, r)
http.NotFound(w, r) return
return }
} quantity, _ := strconv.Atoi(query.Get("product.quantity." + strconv.Itoa(index)))
if pdf { if quantity > 0 {
w.Header().Set("Content-Type", "application/pdf") form.Quantity.Val = strconv.Itoa(quantity)
mustWriteInvoicePdf(w, r, inv) }
} else { w.Header().Set(HxTriggerAfterSettle, "recompute")
mustRenderMainTemplate(w, r, "invoices/view.gohtml", inv) }
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 { for n, product := range products {
if product.Quantity.Val != "0" { if product.Quantity.Val != "0" {
if n != len(form.Products) { if n != len(form.Products) {
product.Reindex(len(form.Products)) product.Index = len(form.Products)
product.Rename()
} }
form.Products = append(form.Products, product) form.Products = append(form.Products, product)
} }
@ -706,6 +726,7 @@ func mustGetTaxOptions(ctx context.Context, conn *Conn, company *Company) []*Sel
type invoiceProductForm struct { type invoiceProductForm struct {
locale *Locale locale *Locale
company *Company company *Company
Index int
InvoiceProductId *InputField InvoiceProductId *InputField
ProductId *InputField ProductId *InputField
Name *InputField Name *InputField
@ -720,6 +741,7 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
form := &invoiceProductForm{ form := &invoiceProductForm{
locale: locale, locale: locale,
company: company, company: company,
Index: index,
InvoiceProductId: &InputField{ InvoiceProductId: &InputField{
Label: pgettext("input", "Id", locale), Label: pgettext("input", "Id", locale),
Type: "hidden", Type: "hidden",
@ -734,6 +756,15 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
Label: pgettext("input", "Name", locale), Label: pgettext("input", "Name", locale),
Type: "text", Type: "text",
Required: true, 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{ Description: &InputField{
Label: pgettext("input", "Description", locale), Label: pgettext("input", "Description", locale),
@ -771,12 +802,12 @@ func newInvoiceProductForm(index int, company *Company, locale *Locale, taxOptio
Options: taxOptions, Options: taxOptions,
}, },
} }
form.Reindex(index) form.Rename()
return form return form
} }
func (form *invoiceProductForm) Reindex(index int) { func (form *invoiceProductForm) Rename() {
suffix := "." + strconv.Itoa(index) suffix := "." + strconv.Itoa(form.Index)
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
@ -820,6 +851,35 @@ func (form *invoiceProductForm) Validate() bool {
return validator.AllOK() 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) { func HandleUpdateInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
locale := getLocale(r) locale := getLocale(r)
conn := getConn(r) conn := getConn(r)

View File

@ -351,13 +351,13 @@ func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
} }
func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var name string filters := newProductFilterForm(getLocale(r))
query := r.URL.Query() query := r.URL.Query()
for k := range query { index := query.Get("index")
vs := query[k] filters.Name.Val = strings.TrimSpace(query.Get("product.name." + index))
if len(vs) > 0 { var products []*ProductEntry
name = vs[0] 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)
} }

View File

@ -33,6 +33,7 @@ func NewRouter(db *Db) http.Handler {
companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction) companyRouter.POST("/invoices/:slug/edit", HandleEditInvoiceAction)
companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags) companyRouter.PUT("/invoices/:slug/tags", HandleUpdateInvoiceTags)
companyRouter.GET("/invoices/:slug/tags/edit", ServeEditInvoiceTags) companyRouter.GET("/invoices/:slug/tags/edit", ServeEditInvoiceTags)
companyRouter.GET("/search/products", HandleProductSearch)
companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { companyRouter.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
mustRenderMainTemplate(w, r, "dashboard.gohtml", nil) mustRenderMainTemplate(w, r, "dashboard.gohtml", nil)
}) })

52
web/static/bars.svg Normal file
View File

@ -0,0 +1,52 @@
<svg width="16" height="16" viewBox="0 0 135 140" xmlns="http://www.w3.org/2000/svg" fill="#494949">
<rect y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.5s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0.5s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
<rect x="30" y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.25s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0.25s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
<rect x="60" width="15" height="140" rx="6">
<animate attributeName="height"
begin="0s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
<rect x="90" y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.25s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0.25s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
<rect x="120" y="10" width="15" height="120" rx="6">
<animate attributeName="height"
begin="0.5s" dur="1s"
values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
repeatCount="indefinite"/>
<animate attributeName="y"
begin="0.5s" dur="1s"
values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
repeatCount="indefinite"/>
</rect>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -614,7 +614,8 @@ main > nav {
max-width: 35rem; 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; font-size: 1em;
list-style: none; list-style: none;
color: var(--numerus--text-color); color: var(--numerus--text-color);
@ -682,6 +683,7 @@ main > nav {
min-width: initial; min-width: initial;
} }
[is="numerus-product-search"] .options,
[is="numerus-multiselect"] .options { [is="numerus-multiselect"] .options {
padding: 0; padding: 0;
border-top: 0; border-top: 0;
@ -691,6 +693,7 @@ main > nav {
z-index: 10; z-index: 10;
} }
[is="numerus-product-search"] .options li,
[is="numerus-multiselect"] .options li { [is="numerus-multiselect"] .options li {
display: block; display: block;
width: 100%; width: 100%;
@ -698,7 +701,8 @@ main > nav {
padding: 1rem 2rem; 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); background-color: var(--numerus--color--light-gray);
} }

View File

@ -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-multiselect', Multiselect, {extends: 'div'});
customElements.define('numerus-tags', Tags, {extends: 'div'}); customElements.define('numerus-tags', Tags, {extends: 'div'});
customElements.define('numerus-product-search', ProductSearch, {extends: 'div'});
let savedTitle = ''; let savedTitle = '';

View File

@ -7,7 +7,7 @@
{{ define "input-field" -}} {{ define "input-field" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.InputField*/ -}}
<div class="input {{ if .Errors }}has-errors{{ end }}"> <div class="input{{ if .Errors }} has-errors{{ end }}"{{ if .Is }} is="{{ .Is }}"{{ end }}>
{{ if eq .Type "textarea" }} {{ if eq .Type "textarea" }}
<textarea name="{{ .Name }}" id="{{ .Name }}-field" <textarea name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }} {{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
@ -112,11 +112,18 @@
{{ define "invoice-product-form" -}} {{ define "invoice-product-form" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}}
{{ template "hidden-field" .ProductId }} <fieldset class="new-invoice-product"
{{ template "input-field" .Name }} data-hx-select="unset"
{{ template "input-field" .Price }} data-hx-vals='{"index": {{ .Index }}}'
{{ template "input-field" .Quantity }} data-hx-include="[name='product.quantity.{{ .Index }}']"
{{ template "input-field" .Discount }} >
{{ template "input-field" .Description }} {{ template "hidden-field" .InvoiceProductId }}
{{ template "select-field" .Tax }} {{ template "hidden-field" .ProductId }}
{{ template "input-field" .Name }}
{{ template "input-field" .Price }}
{{ template "input-field" .Quantity }}
{{ template "input-field" .Discount }}
{{ template "input-field" .Description }}
{{ template "select-field" .Tax }}
</fieldset>
{{- end }} {{- end }}

View File

@ -30,14 +30,11 @@
{{ template "input-field" .Notes }} {{ template "input-field" .Notes }}
{{- range $product := .Products }} {{- range $product := .Products }}
<fieldset class="new-invoice-product"> {{ template "invoice-product-form" . }}
{{ template "hidden-field" .InvoiceProductId }}
{{ template "invoice-product-form" . }}
</fieldset>
{{- end }} {{- end }}
{{- end }} {{- end }}
<table> <table id="invoice-summary">
<tbody> <tbody>
<tr> <tr>
<th scope="row">{{(pgettext "Subtotal" "title")}}</th> <th scope="row">{{(pgettext "Subtotal" "title")}}</th>
@ -59,8 +56,13 @@
<fieldset> <fieldset>
<button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit" <button formnovalidate formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit"
name="action" value="select-products" name="action" value="select-products"
data-hx-get="{{ companyURI "/invoices/product-form" }}"
data-hx-target="#invoice-summary" data-hx-swap="beforebegin"
data-hx-select="unset"
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 formaction="{{ companyURI "/invoices" }}/{{ .Slug }}/edit"
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"
@ -69,4 +71,10 @@
</form> </form>
</section> </section>
<script>
document.body.addEventListener('recompute', function () {
document.getElementById('recompute-button').click();
});
</script>
{{- end }} {{- end }}

View File

@ -30,13 +30,11 @@
{{ template "input-field" .Notes }} {{ template "input-field" .Notes }}
{{- range $product := .Products }} {{- range $product := .Products }}
<fieldset class="new-invoice-product"> {{ template "invoice-product-form" . }}
{{ template "invoice-product-form" . }}
</fieldset>
{{- end }} {{- end }}
{{- end }} {{- end }}
<table> <table id="invoice-summary">
<tbody> <tbody>
<tr> <tr>
<th scope="row">{{(pgettext "Subtotal" "title")}}</th> <th scope="row">{{(pgettext "Subtotal" "title")}}</th>
@ -58,14 +56,24 @@
<fieldset> <fieldset>
<button formnovalidate formaction="{{ companyURI "/invoices/new" }}" <button formnovalidate formaction="{{ companyURI "/invoices/new" }}"
name="action" value="select-products" name="action" value="select-products"
data-hx-get="{{ companyURI "/invoices/product-form" }}"
data-hx-target="#invoice-summary" data-hx-swap="beforebegin"
data-hx-select="unset"
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 formaction="{{ companyURI "/invoices/new" }}"
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"
type="submit">{{( pgettext "New invoice" "action" )}}</button> type="submit">{{( pgettext "New invoice" "action" )}}</button>
</fieldset> </fieldset>
</form> </form>
</section> </section>
<script>
document.body.addEventListener('recompute', function () {
document.getElementById('recompute-button').click();
});
</script>
{{- end }} {{- end }}

View File

@ -0,0 +1,4 @@
{{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}}
{{ template "invoice-product-form" . }}
{{- end }}

View File

@ -0,0 +1,11 @@
{{ define "content" }}
{{- /*gotype: []dev.tandem.ws/tandem/numerus/pkg.ProductEntry*/ -}}
{{- range $product := . }}
<li
data-hx-get="{{ companyURI "/invoices/product-form" }}?slug={{ .Slug }}"
data-hx-target="closest fieldset"
data-hx-swap="outerHTML"
data-hx-trigger="click"
>{{ .Name }}</li>
{{- end }}
{{- end }}