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
273 lines
7.6 KiB
Go
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)
|
|
}
|