Compare commits

...

2 Commits

Author SHA1 Message Date
jordi fita mas 7d895fe5f9 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.
2023-04-24 02:00:38 +02:00
jordi fita mas 2ced61d304 Add the product filter form
I need a way to search products by name in the invoice form, when the
user adds or changes a product.  Since this is something that i have to
add too to the product list, i added it now so the function will already
be ready.
2023-04-23 03:20:01 +02:00
14 changed files with 354 additions and 59 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

@ -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 {

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,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)

View File

@ -8,6 +8,7 @@ import (
"math"
"net/http"
"strconv"
"strings"
)
type ProductEntry struct {
@ -19,14 +20,21 @@ type ProductEntry struct {
type productsIndexPage struct {
Products []*ProductEntry
Filters *productFilterForm
}
func IndexProducts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r)
company := mustGetCompany(r)
tag := r.URL.Query().Get("tag")
locale := getLocale(r)
filters := newProductFilterForm(locale)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
page := &productsIndexPage{
Products: mustCollectProductEntries(r.Context(), conn, company, tag),
Products: mustCollectProductEntries(r.Context(), conn, company, filters),
Filters: filters,
}
mustRenderMainTemplate(w, r, "products/index.gohtml", page)
}
@ -152,8 +160,72 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
}
}
func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, tag string) []*ProductEntry {
rows, err := conn.Query(ctx, `
type productFilterForm struct {
Name *InputField
Tags *TagsField
TagsCondition *ToggleField
}
func newProductFilterForm(locale *Locale) *productFilterForm {
return &productFilterForm{
Name: &InputField{
Name: "number",
Label: pgettext("input", "Invoice Number", locale),
Type: "search",
},
Tags: &TagsField{
Name: "tags",
Label: pgettext("input", "Tags", locale),
},
TagsCondition: &ToggleField{
Name: "tags_condition",
Label: pgettext("input", "Tags Condition", locale),
Selected: "and",
FirstOption: &ToggleOption{
Value: "and",
Label: pgettext("tag condition", "All", locale),
Description: gettext("Invoices must have all the specified labels.", locale),
},
SecondOption: &ToggleOption{
Value: "or",
Label: pgettext("tag condition", "Any", locale),
Description: gettext("Invoices must have at least one of the specified labels.", locale),
},
},
}
}
func (form *productFilterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Name.FillValue(r)
form.Tags.FillValue(r)
form.TagsCondition.FillValue(r)
return nil
}
func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, filters *productFilterForm) []*ProductEntry {
args := []interface{}{company.Id}
where := []string{"product.company_id = $1"}
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
if filters != nil {
name := strings.TrimSpace(filters.Name.String())
if name != "" {
appendWhere("product.name ilike $%d", "%"+name+"%")
}
if len(filters.Tags.Tags) > 0 {
if filters.TagsCondition.Selected == "and" {
appendWhere("product.tags @> $%d", filters.Tags)
} else {
appendWhere("product.tags && $%d", filters.Tags)
}
}
}
rows := conn.MustQuery(ctx, fmt.Sprintf(`
select product.slug
, product.name
, to_price(price, decimal_digits)
@ -161,19 +233,15 @@ func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company
from product
join company using (company_id)
join currency using (currency_code)
where product.company_id = $1 and (($2 = '') or (tags @> array[$2]::tag_name[]))
where (%s)
order by name
`, company.Id, tag)
if err != nil {
panic(err)
}
`, strings.Join(where, ") AND (")), args...)
defer rows.Close()
var entries []*ProductEntry
for rows.Next() {
entry := &ProductEntry{}
err = rows.Scan(&entry.Slug, &entry.Name, &entry.Price, &entry.Tags)
if err != nil {
if err := rows.Scan(&entry.Slug, &entry.Name, &entry.Price, &entry.Tags); err != nil {
panic(err)
}
entries = append(entries, entry)
@ -281,3 +349,15 @@ func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, s
form.Tax,
form.Tags))
}
func HandleProductSearch(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
filters := newProductFilterForm(getLocale(r))
query := r.URL.Query()
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)
}
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,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);
}

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,11 +112,18 @@
{{ define "invoice-product-form" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.invoiceProductForm*/ -}}
{{ 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 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 }}
{{ template "input-field" .Quantity }}
{{ 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>
{{ template "invoice-product-form" . }}
{{- 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>
{{ template "invoice-product-form" . }}
{{- 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

@ -19,6 +19,20 @@
{{ define "content" }}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.productsIndexPage*/ -}}
<div aria-label="{{( pgettext "Filters" "title" )}}">
<form method="GET" action="{{ companyURI "/products"}}"
data-hx-target="main"
data-hx-boost="true"
data-hx-trigger="change,search,submit"
>
{{ with .Filters }}
{{ template "input-field" .Name }}
{{ template "tags-field" .Tags | addTagsAttr (print `data-conditions="` .TagsCondition.Name `-field"`) }}
{{ template "toggle-field" .TagsCondition }}
{{ end }}
<button type="submit">{{( pgettext "Filter" "action" )}}</button>
</form>
</div>
<table>
<thead>
<tr>
@ -37,7 +51,7 @@
<td>
{{- range $index, $tag := .Tags }}
{{- if gt $index 0 }}, {{ end -}}
<a href="?tag={{ . }}" data-hx-target="main" data-hx-boost="true">{{ . }}</a>
<a href="?tags={{ . }}" data-hx-target="main" data-hx-boost="true">{{ . }}</a>
{{- end }}
</td>
<td class="numeric">{{ .Price | formatPrice }}</td>

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