Add expense’s file input to new and edit forms

I had to change MethodOverrider to check whether the form is encoded as
multipart/form-data or i would not be able to get the method field from
forms with files.

For now i add the file manually, i.e., outside add_expense and
edit_expense PL/pgSQL functions, because it was faster for me, but i
will probably add an attach_to_expense function, or something like that,
to avoid having the whole ON CONFLICT logic inside Golang—this belongs
to the database.
This commit is contained in:
jordi fita mas 2023-05-14 18:46:16 +02:00
parent 5d46bbb95b
commit 3161d54aba
8 changed files with 149 additions and 15 deletions

View File

@ -13,12 +13,13 @@ import (
) )
type ExpenseEntry struct { type ExpenseEntry struct {
Slug string Slug string
InvoiceDate time.Time InvoiceDate time.Time
InvoiceNumber string InvoiceNumber string
Amount string Amount string
InvoicerName string InvoicerName string
Tags []string OriginalFileName string
Tags []string
} }
type expensesIndexPage struct { type expensesIndexPage struct {
@ -78,8 +79,10 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, company *Company
, invoice_number , invoice_number
, to_price(amount, decimal_digits) , to_price(amount, decimal_digits)
, contact.business_name , contact.business_name
, coalesce(attachment.original_filename, '')
, expense.tags , expense.tags
from expense from expense
left join expense_attachment as attachment using (expense_id)
join contact using (contact_id) join contact using (contact_id)
join currency using (currency_code) join currency using (currency_code)
where (%s) where (%s)
@ -90,7 +93,7 @@ func mustCollectExpenseEntries(ctx context.Context, conn *Conn, company *Company
var entries []*ExpenseEntry var entries []*ExpenseEntry
for rows.Next() { for rows.Next() {
entry := &ExpenseEntry{} entry := &ExpenseEntry{}
if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.Tags); err != nil { if err := rows.Scan(&entry.Slug, &entry.InvoiceDate, &entry.InvoiceNumber, &entry.Amount, &entry.InvoicerName, &entry.OriginalFileName, &entry.Tags); err != nil {
panic(err) panic(err)
} }
entries = append(entries, entry) entries = append(entries, entry)
@ -148,6 +151,7 @@ type expenseForm struct {
InvoiceDate *InputField InvoiceDate *InputField
Tax *SelectField Tax *SelectField
Amount *InputField Amount *InputField
File *FileField
Tags *TagsField Tags *TagsField
} }
@ -188,6 +192,11 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())), template.HTMLAttr(fmt.Sprintf(`step="%v"`, company.MinCents())),
}, },
}, },
File: &FileField{
Name: "file",
Label: pgettext("input", "File", locale),
MaxSize: 1 << 20,
},
Tags: &TagsField{ Tags: &TagsField{
Name: "tags", Name: "tags",
Label: pgettext("input", "Tags", locale), Label: pgettext("input", "Tags", locale),
@ -196,7 +205,7 @@ func newExpenseForm(ctx context.Context, conn *Conn, locale *Locale, company *Co
} }
func (form *expenseForm) Parse(r *http.Request) error { func (form *expenseForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil { if err := r.ParseMultipartForm(form.File.MaxSize); err != nil {
return err return err
} }
form.Invoicer.FillValue(r) form.Invoicer.FillValue(r)
@ -204,6 +213,9 @@ func (form *expenseForm) Parse(r *http.Request) error {
form.InvoiceDate.FillValue(r) form.InvoiceDate.FillValue(r)
form.Tax.FillValue(r) form.Tax.FillValue(r)
form.Amount.FillValue(r) form.Amount.FillValue(r)
if err := form.File.FillValue(r); err != nil {
return err
}
form.Tags.FillValue(r) form.Tags.FillValue(r)
return nil return nil
} }
@ -269,7 +281,10 @@ func HandleAddExpense(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return return
} }
taxes := mustSliceAtoi(form.Tax.Selected) taxes := mustSliceAtoi(form.Tax.Selected)
conn.MustExec(r.Context(), "select add_expense($1, $2, $3, $4, $5, $6, $7)", company.Id, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags) slug := conn.MustGetText(r.Context(), "", "select add_expense($1, $2, $3, $4, $5, $6, $7)", company.Id, form.InvoiceDate, form.Invoicer, form.InvoiceNumber, form.Amount, taxes, form.Tags)
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "insert into expense_attachment(expense_id, original_filename, mime_type, content) select expense_id, $1, $2, $3 from expense where slug = $4", form.File.OriginalFileName, form.File.ContentType, form.File.Content, slug)
}
htmxRedirect(w, r, companyURI(company, "/expenses")) htmxRedirect(w, r, companyURI(company, "/expenses"))
} }
@ -299,6 +314,9 @@ func HandleUpdateExpense(w http.ResponseWriter, r *http.Request, params httprout
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if len(form.File.Content) > 0 {
conn.MustQuery(r.Context(), "insert into expense_attachment(expense_id, original_filename, mime_type, content) select expense_id, $1, $2, $3 from expense where slug = $4 on conflict (expense_id) do update set original_filename = excluded.original_filename, mime_type = excluded.mime_type, content = excluded.content", form.File.OriginalFileName, form.File.ContentType, form.File.Content, slug)
}
htmxRedirect(w, r, companyURI(company, "/expenses")) htmxRedirect(w, r, companyURI(company, "/expenses"))
} }
@ -405,3 +423,24 @@ func HandleUpdateExpenseTags(w http.ResponseWriter, r *http.Request, params http
} }
mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form) mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form)
} }
func ServeExpenseAttachment(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
slug := params[0].Value
conn := getConn(r)
var contentType string
var content []byte
if notFoundErrorOrPanic(conn.QueryRow(r.Context(), `
select mime_type
, content
from expense
join expense_attachment using (expense_id)
where slug = $1
`, slug).Scan(&contentType, &content)) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(content)), 10))
w.WriteHeader(http.StatusOK)
w.Write(content)
}

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"github.com/jackc/pgtype" "github.com/jackc/pgtype"
"html/template" "html/template"
"io/ioutil"
"net/http" "net/http"
"net/mail" "net/mail"
"net/url" "net/url"
@ -216,6 +217,37 @@ func mustGetCountryOptions(ctx context.Context, conn *Conn, locale *Locale) []*S
return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language) return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", locale.Language)
} }
type FileField struct {
Name string
Label string
MaxSize int64
OriginalFileName string
ContentType string
Content []byte
Errors []error
}
func (field *FileField) FillValue(r *http.Request) error {
file, header, err := r.FormFile(field.Name)
if err != nil {
if err == http.ErrMissingFile {
return nil
}
return err
}
defer file.Close()
field.Content, err = ioutil.ReadAll(file)
if err != nil {
return err
}
field.OriginalFileName = header.Filename
field.ContentType = header.Header.Get("Content-Type")
if len(field.Content) == 0 {
field.ContentType = http.DetectContentType(field.Content)
}
return nil
}
type TagsField struct { type TagsField struct {
Name string Name string
Label string Label string

View File

@ -1,9 +1,9 @@
package pkg package pkg
import ( import (
"net/http"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"mime"
"net/http"
) )
func NewRouter(db *Db) http.Handler { func NewRouter(db *Db) http.Handler {
@ -44,6 +44,7 @@ func NewRouter(db *Db) http.Handler {
companyRouter.PUT("/expenses/:slug", HandleUpdateExpense) companyRouter.PUT("/expenses/:slug", HandleUpdateExpense)
companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags) companyRouter.PUT("/expenses/:slug/tags", HandleUpdateExpenseTags)
companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags) companyRouter.GET("/expenses/:slug/tags/edit", ServeEditExpenseTags)
companyRouter.GET("/expenses/:slug/download/:filename", ServeExpenseAttachment)
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)
}) })
@ -85,10 +86,23 @@ func NewRouter(db *Db) http.Handler {
func MethodOverrider(next http.Handler) http.Handler { func MethodOverrider(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil { contentType := r.Header.Get("Content-Type")
contentType, _, err := mime.ParseMediaType(contentType)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
if contentType == "multipart/form-data" {
if err := r.ParseMultipartForm(20 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
} else {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
override := r.FormValue(overrideMethodName) override := r.FormValue(overrideMethodName)
if override == http.MethodDelete || override == http.MethodPut { if override == http.MethodDelete || override == http.MethodPut {
r2 := new(http.Request) r2 := new(http.Request)

View File

@ -80,6 +80,10 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s
"putMethod": func() template.HTML { "putMethod": func() template.HTML {
return overrideMethodField(http.MethodPut) return overrideMethodField(http.MethodPut)
}, },
"humanizeBytes": func(bytes int64) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return humanizeBytes(bytes, 1024, sizes)
},
}) })
if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil { if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil {
panic(err) panic(err)
@ -129,3 +133,22 @@ func mustRenderStandaloneTemplate(w io.Writer, r *http.Request, filename string,
func mustRenderWebTemplate(w io.Writer, r *http.Request, filename string, data interface{}) { func mustRenderWebTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {
mustRenderTemplate(w, r, "web.gohtml", filename, data) mustRenderTemplate(w, r, "web.gohtml", filename, data)
} }
func humanizeBytes(s int64, base float64, sizes []string) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
e := math.Floor(logn(float64(s), base))
suffix := sizes[int(e)]
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}

View File

@ -18,7 +18,7 @@
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editExpensePage*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.editExpensePage*/ -}}
<section class="dialog-content" id="new-expense-dialog-content" data-hx-target="main"> <section class="dialog-content" id="new-expense-dialog-content" data-hx-target="main">
<h2>{{ printf (pgettext "Edit Expense “%s”" "title") .Slug }}</h2> <h2>{{ printf (pgettext "Edit Expense “%s”" "title") .Slug }}</h2>
<form method="POST" action="{{ companyURI "/expenses/" }}{{ .Slug }}" data-hx-boost="true"> <form enctype="multipart/form-data" method="POST" action="{{ companyURI "/expenses/" }}{{ .Slug }}" data-hx-boost="true">
{{ csrfToken }} {{ csrfToken }}
{{ putMethod }} {{ putMethod }}
@ -28,6 +28,7 @@
{{ template "input-field" .InvoiceDate }} {{ template "input-field" .InvoiceDate }}
{{ template "input-field" .Amount }} {{ template "input-field" .Amount }}
{{ template "select-field" .Tax }} {{ template "select-field" .Tax }}
{{ template "file-field" .File }}
{{ template "tags-field" .Tags }} {{ template "tags-field" .Tags }}
{{- end }} {{- end }}

View File

@ -43,6 +43,7 @@
<th>{{( pgettext "Invoice Number" "title" )}}</th> <th>{{( pgettext "Invoice Number" "title" )}}</th>
<th>{{( pgettext "Tags" "title" )}}</th> <th>{{( pgettext "Tags" "title" )}}</th>
<th>{{( pgettext "Amount" "title" )}}</th> <th>{{( pgettext "Amount" "title" )}}</th>
<th>{{( pgettext "Download" "title" )}}</th>
<th>{{( pgettext "Actions" "title" )}}</th> <th>{{( pgettext "Actions" "title" )}}</th>
</tr> </tr>
</thead> </thead>
@ -65,6 +66,14 @@
{{- end }} {{- end }}
</td> </td>
<td class="numeric">{{ .Amount | formatPrice }}</td> <td class="numeric">{{ .Amount | formatPrice }}</td>
<td class="invoice-download">
{{ if .OriginalFileName }}
<a href="{{ companyURI "/expenses/"}}{{ .Slug }}/download/{{.OriginalFileName}}"
title="{{( pgettext "Download expense attachment" "action" )}}"
aria-label="{{( pgettext "Download expense attachment" "action" )}}"><i
class="ri-download-line"></i></a>
{{ end }}
</td>
<td class="actions"> <td class="actions">
<details class="menu"> <details class="menu">
<summary><i class="ri-more-line"></i></summary> <summary><i class="ri-more-line"></i></summary>
@ -84,7 +93,7 @@
{{- end }} {{- end }}
{{ else }} {{ else }}
<tr> <tr>
<td colspan="7">{{( gettext "No expenses added yet." )}}</td> <td colspan="8">{{( gettext "No expenses added yet." )}}</td>
</tr> </tr>
{{ end }} {{ end }}
</tbody> </tbody>

View File

@ -18,7 +18,7 @@
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.expenseForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.expenseForm*/ -}}
<section class="dialog-content" id="new-expense-dialog-content" data-hx-target="main"> <section class="dialog-content" id="new-expense-dialog-content" data-hx-target="main">
<h2>{{(pgettext "New Expense" "title")}}</h2> <h2>{{(pgettext "New Expense" "title")}}</h2>
<form method="POST" action="{{ companyURI "/expenses" }}" data-hx-boost="true"> <form enctype="multipart/form-data" method="POST" action="{{ companyURI "/expenses" }}" data-hx-boost="true">
{{ csrfToken }} {{ csrfToken }}
{{ template "select-field" .Invoicer }} {{ template "select-field" .Invoicer }}
@ -26,6 +26,7 @@
{{ template "input-field" .InvoiceDate }} {{ template "input-field" .InvoiceDate }}
{{ template "input-field" .Amount }} {{ template "input-field" .Amount }}
{{ template "select-field" .Tax }} {{ template "select-field" .Tax }}
{{ template "file-field" .File }}
{{ template "tags-field" .Tags }} {{ template "tags-field" .Tags }}
<fieldset> <fieldset>

View File

@ -29,6 +29,21 @@
</div> </div>
{{- end }} {{- end }}
{{ define "file-field" -}}
{{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.FileField*/ -}}
<div class="input{{ if .Errors }} has-errors{{ end }}">
<input type="file" name="{{ .Name }}" id="{{ .Name }}-field" placeholder="{{ .Label }}">
<label for="{{ .Name }}-field">{{ .Label }}{{ if gt .MaxSize 0 }} {{printf (pgettext "(Max. %s)" "label") (.MaxSize|humanizeBytes) }}{{ end }}</label>
{{- if .Errors }}
<ul>
{{- range $error := .Errors }}
<li>{{ . }}</li>
{{- end }}
</ul>
{{- end }}
</div>
{{- end }}
{{ define "tags-field" -}} {{ define "tags-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 }}" is="numerus-tags"> <div class="input {{ if .Errors }}has-errors{{ end }}" is="numerus-tags">