camper/pkg/services/admin.go
jordi fita mas 678b5cc523 Add user-defined order to campsite types, options, seasons and carousels
I use Sortable, exactly like HTMx’s sorting example does[0].  Had to
export the slug or ID of some entries to be able to add it in the hidden
input.

For forms that use ID instead of slug, had to use an input name other
than “id” because otherwise the swap would fail due to bug #1496[1].  It
is apparently fixed in a recent version of HTMx, but i did not want to
update for fear of behaviour changes.

[0]: https://htmx.org/examples/sortable/
[1]: https://github.com/bigskysoftware/htmx/issues/1496
2023-12-20 19:52:14 +01:00

273 lines
7.6 KiB
Go

/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package services
import (
"context"
"net/http"
"strconv"
"github.com/jackc/pgx/v4"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/carousel"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
const carouselName = "services"
type AdminHandler struct {
locales locale.Locales
carousel *carousel.AdminHandler
}
func NewAdminHandler(locales locale.Locales) *AdminHandler {
return &AdminHandler{
locales: locales,
carousel: carousel.NewAdminHandler(carouselName, serveServicesIndex, locales),
}
}
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
serveServicesIndex(w, r, user, company, conn)
case http.MethodPost:
addService(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "new":
switch r.Method {
case http.MethodGet:
f := newServiceForm(r.Context(), conn)
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "slides":
h.carousel.Handler(user, company, conn).ServeHTTP(w, r)
default:
id, err := strconv.Atoi(head)
if err != nil {
http.NotFound(w, r)
return
}
f := newServiceForm(r.Context(), conn)
if err := f.FillFromDatabase(r.Context(), conn, id); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
var langTag string
langTag, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch langTag {
case "":
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
editService(w, r, user, company, conn, f)
case http.MethodDelete:
deleteService(w, r, user, conn, id)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut, http.MethodDelete)
}
default:
loc, ok := h.locales.Get(langTag)
if !ok {
http.NotFound(w, r)
return
}
l10n := newServiceL10nForm(f, loc)
if err := l10n.FillFromDatabase(r.Context(), conn); err != nil {
panic(err)
}
switch r.Method {
case http.MethodGet:
l10n.MustRender(w, r, user, company)
case http.MethodPut:
editServiceL10n(w, r, user, company, conn, l10n)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
}
}
})
}
func serveServicesIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
slides, err := carousel.CollectSlideEntries(r.Context(), company, conn, carouselName)
if err != nil {
panic(err)
}
services, err := collectServiceEntries(r.Context(), company, conn)
page := &servicesIndex{
Services: services,
Slides: slides,
}
page.MustRender(w, r, user, company)
}
type servicesIndex struct {
Services []*serviceEntry
Slides []*carousel.SlideEntry
}
func (page *servicesIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "services/index.gohtml", page)
}
type serviceEntry struct {
URL string
Icon string
Name string
Translations []*locale.Translation
}
func collectServiceEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*serviceEntry, error) {
rows, err := conn.Query(ctx, `
select '/admin/services/' || service_id
, icon_name
, service.name
, array_agg((lang_tag, endonym, not exists (select 1 from service_i18n as i18n where i18n.service_id = service.service_id and i18n.lang_tag = language.lang_tag))::translation order by endonym)
from service
join company using (company_id)
, language
where lang_tag <> default_lang_tag
and language.selectable
and service.company_id = $1
group by service_id
, icon_name
, service.name
order by service.name
`, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var services []*serviceEntry
for rows.Next() {
entry := &serviceEntry{}
var translations database.RecordArray
if err = rows.Scan(&entry.URL, &entry.Icon, &entry.Name, &translations); err != nil {
return nil, err
}
for _, el := range translations.Elements {
entry.Translations = append(entry.Translations, &locale.Translation{
URL: entry.URL + "/" + el.Fields[0].Get().(string),
Endonym: el.Fields[1].Get().(string),
Missing: el.Fields[2].Get().(bool),
})
}
services = append(services, entry)
}
return services, nil
}
func addService(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newServiceForm(r.Context(), conn)
if ok, err := form.Handle(f, w, r, user); err != nil {
return
} else if !ok {
f.MustRender(w, r, user, company)
return
}
conn.MustExec(r.Context(), "select add_service($1, $2, $3, $4)", company.ID, f.Icon, f.Name, f.Description)
httplib.Redirect(w, r, "/admin/services", http.StatusSeeOther)
}
func editService(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *serviceForm) {
if ok, err := form.Handle(f, w, r, user); err != nil {
return
} else if !ok {
f.MustRender(w, r, user, company)
return
}
conn.MustExec(r.Context(), "select edit_service($1, $2, $3, $4)", f.ID, f.Icon, f.Name, f.Description)
httplib.Redirect(w, r, "/admin/services", http.StatusSeeOther)
}
func deleteService(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn, id int) {
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn.MustExec(r.Context(), "select remove_service($1)", id)
httplib.Redirect(w, r, "/admin/services", http.StatusSeeOther)
}
type serviceForm struct {
ID int
Icon *form.Select
Name *form.Input
Description *form.Input
}
func newServiceForm(ctx context.Context, conn *database.Conn) *serviceForm {
return &serviceForm{
Icon: &form.Select{
Name: "icon",
Options: form.MustGetOptions(ctx, conn, "select icon_name, icon_name from icon order by 1"),
},
Name: &form.Input{
Name: "name",
},
Description: &form.Input{
Name: "description",
},
}
}
func (f *serviceForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id
row := conn.QueryRow(ctx, `
select array[icon_name]
, name
, description
from service
where service_id = $1
`, id)
return row.Scan(&f.Icon.Selected, &f.Name.Val, &f.Description.Val)
}
func (f *serviceForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Icon.FillValue(r)
f.Name.FillValue(r)
f.Description.FillValue(r)
return nil
}
func (f *serviceForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.CheckSelectedOptions(f.Icon, l.GettextNoop("Selected icon is not valid."))
v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
return v.AllOK
}
func (f *serviceForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "services/form.gohtml", f)
}