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
Type string
Val string
Is string
Required bool
Attributes []template.HTMLAttr
Errors []error

View File

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

View File

@ -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,9 +234,26 @@ 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)
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
@ -253,6 +271,7 @@ func ServeInvoice(w http.ResponseWriter, r *http.Request, params httprouter.Para
mustRenderMainTemplate(w, r, "invoices/view.gohtml", inv)
}
}
}
func mustWriteInvoicePdf(w io.Writer, r *http.Request, inv *invoice) {
cmd := exec.Command("weasyprint", "--format", "pdf", "--stylesheet", "web/static/invoice.css", "-", "-")
@ -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)

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) {
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)
}

View File

@ -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)
})

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;
}
[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,6 +701,7 @@ main > nav {
padding: 1rem 2rem;
}
[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);
}

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

View File

@ -7,7 +7,7 @@
{{ define "input-field" -}}
{{- /*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" }}
<textarea name="{{ .Name }}" id="{{ .Name }}-field"
{{- range $attribute := .Attributes }} {{$attribute}} {{ end }}
@ -112,6 +112,12 @@
{{ define "invoice-product-form" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}}
<fieldset class="new-invoice-product"
data-hx-select="unset"
data-hx-vals='{"index": {{ .Index }}}'
data-hx-include="[name='product.quantity.{{ .Index }}']"
>
{{ template "hidden-field" .InvoiceProductId }}
{{ template "hidden-field" .ProductId }}
{{ template "input-field" .Name }}
{{ template "input-field" .Price }}
@ -119,4 +125,5 @@
{{ template "input-field" .Discount }}
{{ template "input-field" .Description }}
{{ template "select-field" .Tax }}
</fieldset>
{{- end }}

View File

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

View File

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