Add dish CRUD to the UI, including inline from Kirjaa
The catalog could only be filled by importing JSON, which is a poor way to add the one dish you are about to eat. Ruoat now covers PRD §7.3 in full: add and edit mains with their categories and has_sides, add and edit sides, and delete either. Deletes are soft, so a log entry keeps resolving the dish it used and the freed name can be reused. Validation messages are Finnish and the rejected form comes back filled in rather than blank. Bulk import moves into a details element, since it is now the occasional path rather than the only one. Kirjaa gets the same ability without the detour: a search that finds nothing offers to add what was typed, and saving creates the dish and continues straight to the sides step. An empty catalog shows the same card instead of dead-ending on a link to another tab, and the search box is no longer hidden behind the empty state. The importer's own insert is gone; it and the UI both go through createMain and createSide, so duplicate detection lives in one place and reason() can match on errNameTaken instead of poking at driver strings.
This commit is contained in:
@@ -21,13 +21,14 @@ Working:
|
|||||||
by how often they are eaten. Edit or delete the day's entry.
|
by how often they are eaten. Edit or delete the day's entry.
|
||||||
- **Historia** — every day back to the first entry, with unlogged days shown
|
- **Historia** — every day back to the first entry, with unlogged days shown
|
||||||
as explicit gaps.
|
as explicit gaps.
|
||||||
- **Ruoat** — import a bundle of dishes by paste or file upload.
|
- **Ruoat** — add, edit and delete mains and sides, or import a whole bundle
|
||||||
|
by paste or file upload. Deletes are soft, so old log entries keep showing
|
||||||
|
the dish they used.
|
||||||
|
|
||||||
Still to build:
|
Still to build:
|
||||||
|
|
||||||
- Adding, editing and deleting dishes **in the UI** (PRD §7.3). Right now the
|
|
||||||
catalog can only be filled by importing a bundle.
|
|
||||||
- Light / dark theme switch, saved per device (PRD §7.4).
|
- Light / dark theme switch, saved per device (PRD §7.4).
|
||||||
|
- A proper app header and a `favicon.svg`.
|
||||||
- Stage 2: the seven-meal suggester, which starts once there is history to
|
- Stage 2: the seven-meal suggester, which starts once there is history to
|
||||||
weight against.
|
weight against.
|
||||||
|
|
||||||
|
|||||||
+6
-35
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -102,7 +103,7 @@ func importBundle(db *sql.DB, r io.Reader) (*ImportReport, error) {
|
|||||||
hasSides = *m.HasSides
|
hasSides = *m.HasSides
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := insertMain(db, name, m.Categories, hasSides); err != nil {
|
if _, err := createMain(db, name, m.Categories, hasSides); err != nil {
|
||||||
report.skip("%s: %s", name, reason(err))
|
report.skip("%s: %s", name, reason(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,7 @@ func importBundle(db *sql.DB, r io.Reader) (*ImportReport, error) {
|
|||||||
report.skip("lisuke ilman nimeä")
|
report.skip("lisuke ilman nimeä")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`INSERT INTO side_dishes (name) VALUES (?)`, name); err != nil {
|
if err := createSide(db, name); err != nil {
|
||||||
report.skip("%s: %s", name, reason(err))
|
report.skip("%s: %s", name, reason(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -125,45 +126,15 @@ func importBundle(db *sql.DB, r io.Reader) (*ImportReport, error) {
|
|||||||
return report, nil
|
return report, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reason turns a driver error into something worth showing a person. The
|
// reason turns a store error into something worth showing a person. The only
|
||||||
// only failure a normal import hits is a name already in the catalog.
|
// failure a normal import hits is a name already in the catalog.
|
||||||
//
|
|
||||||
// ponytail: string match rather than unwrapping a driver-specific error type,
|
|
||||||
// so this keeps working if the driver is ever swapped.
|
|
||||||
func reason(err error) string {
|
func reason(err error) string {
|
||||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
if errors.Is(err, errNameTaken) {
|
||||||
return "jo listalla"
|
return "jo listalla"
|
||||||
}
|
}
|
||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertMain(db *sql.DB, name string, categories []string, hasSides bool) error {
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
res, err := tx.Exec(
|
|
||||||
`INSERT INTO main_dishes (name, has_sides) VALUES (?, ?)`, name, hasSides)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
id, err := res.LastInsertId()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, c := range categories {
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
`INSERT OR IGNORE INTO main_dish_categories (main_dish_id, category) VALUES (?, ?)`,
|
|
||||||
id, c,
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// runImport loads a bundle file, prints the report, and is what `-import`
|
// runImport loads a bundle file, prints the report, and is what `-import`
|
||||||
// calls. Handy for reseeding a scratch database between test runs.
|
// calls. Handy for reseeding a scratch database between test runs.
|
||||||
func runImport(db *sql.DB, path string) error {
|
func runImport(db *sql.DB, path string) error {
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateMain(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
|
||||||
|
id, err := createMain(h.db, "Uunikala", []string{"fish"}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createMain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dish, err := dishByID(h.db, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dishByID: %v", err)
|
||||||
|
}
|
||||||
|
if dish.Name != "Uunikala" || !dish.HasSides {
|
||||||
|
t.Errorf("got %+v", dish)
|
||||||
|
}
|
||||||
|
if len(dish.Categories) != 1 || dish.Categories[0] != "fish" {
|
||||||
|
t.Errorf("categories = %v, want [fish]", dish.Categories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateMainRejectsDuplicateName(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
|
||||||
|
// Seeded bundle already has Lohikeitto; casing must not matter.
|
||||||
|
_, err := createMain(h.db, "lohikeitto", []string{"fish"}, true)
|
||||||
|
if !errors.Is(err, errNameTaken) {
|
||||||
|
t.Errorf("err = %v, want errNameTaken", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMainReplacesCategories(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
id := h.mainNamed(t, "Tortillat")
|
||||||
|
|
||||||
|
if err := updateMain(h.db, id, "Tortillat", []string{"chicken"}, false); err != nil {
|
||||||
|
t.Fatalf("updateMain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dish, err := dishByID(h.db, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dishByID: %v", err)
|
||||||
|
}
|
||||||
|
if len(dish.Categories) != 1 || dish.Categories[0] != "chicken" {
|
||||||
|
t.Errorf("categories = %v, want [chicken]", dish.Categories)
|
||||||
|
}
|
||||||
|
if dish.HasSides {
|
||||||
|
t.Error("has_sides should have been cleared")
|
||||||
|
}
|
||||||
|
if got := dish.CategoryKey(); got != "kana" {
|
||||||
|
t.Errorf("CategoryKey = %q, want kana", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMainRejectsAnotherDishesName(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
|
||||||
|
err := updateMain(h.db, h.mainNamed(t, "Tortillat"), "Lohikeitto", []string{"fish"}, true)
|
||||||
|
if !errors.Is(err, errNameTaken) {
|
||||||
|
t.Errorf("err = %v, want errNameTaken", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSideCRUD(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
|
||||||
|
if err := createSide(h.db, "Lohkoperunat"); err != nil {
|
||||||
|
t.Fatalf("createSide: %v", err)
|
||||||
|
}
|
||||||
|
if err := createSide(h.db, "lohkoperunat"); !errors.Is(err, errNameTaken) {
|
||||||
|
t.Errorf("duplicate side err = %v, want errNameTaken", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := h.sideNamed(t, "Lohkoperunat")
|
||||||
|
if err := updateSide(h.db, id, "Lohkoperunat uunista"); err != nil {
|
||||||
|
t.Fatalf("updateSide: %v", err)
|
||||||
|
}
|
||||||
|
side, err := sideByID(h.db, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sideByID: %v", err)
|
||||||
|
}
|
||||||
|
if side.Name != "Lohkoperunat uunista" {
|
||||||
|
t.Errorf("name = %q", side.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoftDeleteHidesDishButKeepsHistory(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
id := h.mainNamed(t, "Lohikeitto")
|
||||||
|
date := day(t, "2026-09-05")
|
||||||
|
|
||||||
|
if err := saveEntry(h.db, date, id, nil); err != nil {
|
||||||
|
t.Fatalf("saveEntry: %v", err)
|
||||||
|
}
|
||||||
|
if err := softDeleteMain(h.db, id); err != nil {
|
||||||
|
t.Fatalf("softDeleteMain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gone from the pickers...
|
||||||
|
dishes, err := listDishes(h.db, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listDishes: %v", err)
|
||||||
|
}
|
||||||
|
for _, d := range dishes {
|
||||||
|
if d.ID == id {
|
||||||
|
t.Fatal("soft-deleted dish still appears in the catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := dishByID(h.db, id); err == nil {
|
||||||
|
t.Error("dishByID returned a soft-deleted dish")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...but the log entry still resolves its name (PRD §6).
|
||||||
|
entry, err := entryFor(h.db, date)
|
||||||
|
if err != nil || entry == nil {
|
||||||
|
t.Fatalf("entryFor: %v, %v", entry, err)
|
||||||
|
}
|
||||||
|
if entry.Main.Name != "Lohikeitto" {
|
||||||
|
t.Errorf("historical name = %q, want Lohikeitto", entry.Main.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the freed name can be reused.
|
||||||
|
if _, err := createMain(h.db, "Lohikeitto", []string{"fish"}, true); err != nil {
|
||||||
|
t.Errorf("name still blocked after soft delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
|
||||||
|
h := seeded(t)
|
||||||
|
id := h.sideNamed(t, "Riisi")
|
||||||
|
|
||||||
|
if err := softDeleteSide(h.db, id); err != nil {
|
||||||
|
t.Fatalf("softDeleteSide: %v", err)
|
||||||
|
}
|
||||||
|
sides, err := listSides(h.db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listSides: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range sides {
|
||||||
|
if s.ID == id {
|
||||||
|
t.Fatal("soft-deleted side still appears")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+240
-15
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -54,6 +55,7 @@ type logView struct {
|
|||||||
Checked map[int64]bool // sides ticked in that step
|
Checked map[int64]bool // sides ticked in that step
|
||||||
Dishes []Dish
|
Dishes []Dish
|
||||||
Sides []Side
|
Sides []Side
|
||||||
|
New mainForm // inline "add the dish you were looking for"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -91,6 +93,9 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
||||||
log.Printf("list dishes: %v", err)
|
log.Printf("list dishes: %v", err)
|
||||||
}
|
}
|
||||||
|
// Seed the inline add form with whatever was searched for, so a miss
|
||||||
|
// turns straight into "add it" without retyping.
|
||||||
|
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
|
||||||
}
|
}
|
||||||
if v.Chosen != nil && v.Chosen.HasSides {
|
if v.Chosen != nil && v.Chosen.HasSides {
|
||||||
if v.Sides, err = listSides(a.db); err != nil {
|
if v.Sides, err = listSides(a.db); err != nil {
|
||||||
@@ -101,6 +106,71 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
render(w, r, logPage(v))
|
render(w, r, logPage(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quickAdd creates a dish from the Kirjaa screen and goes straight on to
|
||||||
|
// logging it. Hunting for something that is not in the catalog yet should not
|
||||||
|
// mean a detour through Ruoat and a lost train of thought.
|
||||||
|
func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
date := a.date(r)
|
||||||
|
|
||||||
|
form := mainForm{
|
||||||
|
Name: normalizeName(r.FormValue("nimi")),
|
||||||
|
Categories: map[string]bool{},
|
||||||
|
HasSides: r.FormValue("lisukkeita") != "",
|
||||||
|
}
|
||||||
|
var categories []string
|
||||||
|
for _, c := range r.Form["kategoria"] {
|
||||||
|
if validCategories[c] {
|
||||||
|
categories = append(categories, c)
|
||||||
|
form.Categories[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case form.Name == "":
|
||||||
|
form.Err = "Anna nimi."
|
||||||
|
case len(categories) == 0:
|
||||||
|
form.Err = "Valitse vähintään yksi kategoria."
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Err == "" {
|
||||||
|
id, err := createMain(a.db, form.Name, categories, form.HasSides)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errNameTaken):
|
||||||
|
form.Err = "Nimi on jo listalla."
|
||||||
|
case err != nil:
|
||||||
|
log.Printf("quick add: %v", err)
|
||||||
|
form.Err = "Tallennus epäonnistui."
|
||||||
|
default:
|
||||||
|
a.redirectToPick(w, r, date, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejected: back to the board with the form filled in and the search
|
||||||
|
// still narrowed, so the add card stays on screen.
|
||||||
|
v := logView{
|
||||||
|
Date: date,
|
||||||
|
Today: today(a.loc),
|
||||||
|
Search: form.Name,
|
||||||
|
Checked: map[int64]bool{},
|
||||||
|
New: form,
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
||||||
|
log.Printf("list dishes: %v", err)
|
||||||
|
}
|
||||||
|
render(w, r, logPage(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.Time, id int64) {
|
||||||
|
target := dayURL("/", date, today(a.loc))
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(target, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// save records the meal and redirects, so a refresh cannot double-post.
|
// save records the meal and redirects, so a refresh cannot double-post.
|
||||||
func (a *app) save(w http.ResponseWriter, r *http.Request) {
|
func (a *app) save(w http.ResponseWriter, r *http.Request) {
|
||||||
date := a.date(r)
|
date := a.date(r)
|
||||||
@@ -152,20 +222,175 @@ func (a *app) history(w http.ResponseWriter, r *http.Request) {
|
|||||||
render(w, r, historyPage(rows))
|
render(w, r, historyPage(rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
// mainForm and sideForm carry what the user typed, so a rejected submission
|
||||||
a.renderCatalog(w, r, nil)
|
// comes back filled in rather than blank.
|
||||||
|
type mainForm struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Categories map[string]bool
|
||||||
|
HasSides bool
|
||||||
|
Err string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, report *ImportReport) {
|
type sideForm struct {
|
||||||
var mains, sides int
|
ID int64
|
||||||
err := a.db.QueryRow(`
|
Name string
|
||||||
SELECT (SELECT count(*) FROM main_dishes WHERE deleted_at IS NULL),
|
Err string
|
||||||
(SELECT count(*) FROM side_dishes WHERE deleted_at IS NULL)`,
|
}
|
||||||
).Scan(&mains, &sides)
|
|
||||||
if err != nil {
|
type catalogView struct {
|
||||||
log.Printf("catalog counts: %v", err)
|
Mains []Dish
|
||||||
|
Sides []Side
|
||||||
|
Main mainForm
|
||||||
|
Side sideForm
|
||||||
|
Report *ImportReport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v := catalogView{
|
||||||
|
Main: mainForm{Categories: map[string]bool{}, HasSides: true},
|
||||||
}
|
}
|
||||||
render(w, r, catalogPage(mains, sides, report))
|
|
||||||
|
// ?muokkaa= loads a dish into its form; the same form adds and edits.
|
||||||
|
if raw := r.URL.Query().Get("muokkaa"); raw != "" {
|
||||||
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
if dish, err := dishByID(a.db, id); err == nil {
|
||||||
|
v.Main = mainForm{
|
||||||
|
ID: dish.ID,
|
||||||
|
Name: dish.Name,
|
||||||
|
Categories: map[string]bool{},
|
||||||
|
HasSides: dish.HasSides,
|
||||||
|
}
|
||||||
|
for _, c := range dish.Categories {
|
||||||
|
v.Main.Categories[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw := r.URL.Query().Get("muokkaa-lisuke"); raw != "" {
|
||||||
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
if side, err := sideByID(a.db, id); err == nil {
|
||||||
|
v.Side = sideForm{ID: side.ID, Name: side.Name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderCatalog(w, r, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
|
||||||
|
if v.Main.Categories == nil {
|
||||||
|
v.Main.Categories = map[string]bool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if v.Mains, err = listDishes(a.db, ""); err != nil {
|
||||||
|
log.Printf("list mains: %v", err)
|
||||||
|
}
|
||||||
|
if v.Sides, err = listSides(a.db); err != nil {
|
||||||
|
log.Printf("list sides: %v", err)
|
||||||
|
}
|
||||||
|
render(w, r, catalogPage(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveMain adds or updates a main dish. A rejected form is re-rendered with
|
||||||
|
// the values still in it; a good one redirects, so refresh cannot re-submit.
|
||||||
|
func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
form := mainForm{
|
||||||
|
Name: normalizeName(r.FormValue("nimi")),
|
||||||
|
Categories: map[string]bool{},
|
||||||
|
HasSides: r.FormValue("lisukkeita") != "",
|
||||||
|
}
|
||||||
|
if raw := r.FormValue("id"); raw != "" {
|
||||||
|
form.ID, _ = strconv.ParseInt(raw, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories []string
|
||||||
|
for _, c := range r.Form["kategoria"] {
|
||||||
|
if validCategories[c] {
|
||||||
|
categories = append(categories, c)
|
||||||
|
form.Categories[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case form.Name == "":
|
||||||
|
form.Err = "Anna nimi."
|
||||||
|
case len(categories) == 0:
|
||||||
|
form.Err = "Valitse vähintään yksi kategoria."
|
||||||
|
}
|
||||||
|
if form.Err == "" {
|
||||||
|
var err error
|
||||||
|
if form.ID == 0 {
|
||||||
|
_, err = createMain(a.db, form.Name, categories, form.HasSides)
|
||||||
|
} else {
|
||||||
|
err = updateMain(a.db, form.ID, form.Name, categories, form.HasSides)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errNameTaken):
|
||||||
|
form.Err = "Nimi on jo listalla."
|
||||||
|
case err != nil:
|
||||||
|
log.Printf("save main: %v", err)
|
||||||
|
form.Err = "Tallennus epäonnistui."
|
||||||
|
default:
|
||||||
|
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.renderCatalog(w, r, catalogView{Main: form})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
|
||||||
|
form := sideForm{Name: normalizeName(r.FormValue("nimi"))}
|
||||||
|
if raw := r.FormValue("id"); raw != "" {
|
||||||
|
form.ID, _ = strconv.ParseInt(raw, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Name == "" {
|
||||||
|
form.Err = "Anna nimi."
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
if form.ID == 0 {
|
||||||
|
err = createSide(a.db, form.Name)
|
||||||
|
} else {
|
||||||
|
err = updateSide(a.db, form.ID, form.Name)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errNameTaken):
|
||||||
|
form.Err = "Nimi on jo listalla."
|
||||||
|
case err != nil:
|
||||||
|
log.Printf("save side: %v", err)
|
||||||
|
form.Err = "Tallennus epäonnistui."
|
||||||
|
default:
|
||||||
|
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.renderCatalog(w, r, catalogView{Side: form})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteDish soft-deletes, so log entries keep resolving the name (PRD §6).
|
||||||
|
//
|
||||||
|
// ponytail: no confirmation step. The row survives and history still reads
|
||||||
|
// correctly; add a confirm or an undo list if anyone actually mis-taps.
|
||||||
|
func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "tuntematon ruoka", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.FormValue("tyyppi") == "lisuke" {
|
||||||
|
err = softDeleteSide(a.db, id)
|
||||||
|
} else {
|
||||||
|
err = softDeleteMain(a.db, id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("delete dish: %v", err)
|
||||||
|
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// importDishes takes a bundle either pasted into the textarea or uploaded as a
|
// importDishes takes a bundle either pasted into the textarea or uploaded as a
|
||||||
@@ -178,7 +403,7 @@ func (a *app) importDishes(w http.ResponseWriter, r *http.Request) {
|
|||||||
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
|
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
|
||||||
|
|
||||||
if err := r.ParseMultipartForm(maxUpload); err != nil {
|
if err := r.ParseMultipartForm(maxUpload); err != nil {
|
||||||
a.renderCatalog(w, r, failed("Tiedosto on liian suuri tai vioittunut."))
|
a.renderCatalog(w, r, catalogView{Report: failed("Tiedosto on liian suuri tai vioittunut.")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,16 +414,16 @@ func (a *app) importDishes(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else if pasted := strings.TrimSpace(r.FormValue("json")); pasted != "" {
|
} else if pasted := strings.TrimSpace(r.FormValue("json")); pasted != "" {
|
||||||
src = strings.NewReader(pasted)
|
src = strings.NewReader(pasted)
|
||||||
} else {
|
} else {
|
||||||
a.renderCatalog(w, r, failed("Ei tuotavaa: liitä JSON tai valitse tiedosto."))
|
a.renderCatalog(w, r, catalogView{Report: failed("Ei tuotavaa: liitä JSON tai valitse tiedosto.")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
report, err := importBundle(a.db, src)
|
report, err := importBundle(a.db, src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.renderCatalog(w, r, failed("JSON ei kelpaa: "+err.Error()))
|
a.renderCatalog(w, r, catalogView{Report: failed("JSON ei kelpaa: " + err.Error())})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.renderCatalog(w, r, report)
|
a.renderCatalog(w, r, catalogView{Report: report})
|
||||||
}
|
}
|
||||||
|
|
||||||
// failed builds a report for a whole-request failure, so the view only ever
|
// failed builds a report for a whole-request failure, so the view only ever
|
||||||
|
|||||||
@@ -146,9 +146,13 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
|||||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
mux.HandleFunc("GET /{$}", a.index)
|
mux.HandleFunc("GET /{$}", a.index)
|
||||||
mux.HandleFunc("POST /kirjaa", a.save)
|
mux.HandleFunc("POST /kirjaa", a.save)
|
||||||
|
mux.HandleFunc("POST /lisaa", a.quickAdd)
|
||||||
mux.HandleFunc("POST /poista", a.delete)
|
mux.HandleFunc("POST /poista", a.delete)
|
||||||
mux.HandleFunc("GET /historia", a.history)
|
mux.HandleFunc("GET /historia", a.history)
|
||||||
mux.HandleFunc("GET /ruoat", a.catalog)
|
mux.HandleFunc("GET /ruoat", a.catalog)
|
||||||
|
mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain)
|
||||||
|
mux.HandleFunc("POST /ruoat/lisuke", a.saveSide)
|
||||||
|
mux.HandleFunc("POST /ruoat/poista", a.deleteDish)
|
||||||
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
|
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
|
||||||
|
|
||||||
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
|
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
|
||||||
|
|||||||
@@ -346,6 +346,77 @@ button, input, select { font: inherit; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Catalog rows */
|
||||||
|
.sechead {
|
||||||
|
margin: 26px 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
min-height: var(--tap);
|
||||||
|
padding: 11px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.rowtext { flex: 1; min-width: 0; }
|
||||||
|
.rowtext .nm { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; }
|
||||||
|
.rowtext .sd { font-size: 12.5px; color: var(--muted); }
|
||||||
|
.rowactions { display: flex; align-items: center; gap: 4px; flex: none; }
|
||||||
|
.rowactions a, .rowactions button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rowactions a:hover { color: var(--accent); }
|
||||||
|
.rowactions .del { color: var(--liha); }
|
||||||
|
|
||||||
|
.field input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--tap);
|
||||||
|
background: var(--sunk);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.chip.wide { width: 100%; margin-bottom: 14px; border-radius: 10px; gap: 8px; }
|
||||||
|
.chip .dot { margin-right: 6px; }
|
||||||
|
.formerr {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid var(--liha);
|
||||||
|
background: var(--sunk);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
details.card > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
details.card[open] > summary { margin-bottom: 10px; }
|
||||||
|
details.card > section.card {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
.report { border-left: 4px solid var(--accent); }
|
.report { border-left: 4px solid var(--accent); }
|
||||||
.report.bad { border-left-color: var(--liha); }
|
.report.bad { border-left-color: var(--liha); }
|
||||||
.report .tally { margin: 0; font-weight: 700; }
|
.report .tally { margin: 0; font-weight: 700; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -232,6 +233,114 @@ func deleteEntry(db *sql.DB, date time.Time) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errNameTaken is returned when a name collides with a live dish. The
|
||||||
|
// comparison is case-insensitive and ignores soft-deleted rows (PRD §7.3).
|
||||||
|
var errNameTaken = errors.New("nimi on jo listalla")
|
||||||
|
|
||||||
|
func taken(err error) error {
|
||||||
|
// ponytail: string match rather than a driver-specific error type, so
|
||||||
|
// this survives swapping the driver.
|
||||||
|
if err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
|
return errNameTaken
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// createMain inserts a main dish and its categories in one transaction.
|
||||||
|
func createMain(db *sql.DB, name string, categories []string, hasSides bool) (int64, error) {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`INSERT INTO main_dishes (name, has_sides) VALUES (?, ?)`, name, hasSides)
|
||||||
|
if err != nil {
|
||||||
|
return 0, taken(err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := setCategories(tx, id, categories); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return id, tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateMain rewrites a main dish, replacing its category set wholesale.
|
||||||
|
func updateMain(db *sql.DB, id int64, name string, categories []string, hasSides bool) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE main_dishes SET name = ?, has_sides = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
name, hasSides, id,
|
||||||
|
); err != nil {
|
||||||
|
return taken(err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM main_dish_categories WHERE main_dish_id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := setCategories(tx, id, categories); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCategories(tx *sql.Tx, mainID int64, categories []string) error {
|
||||||
|
for _, c := range categories {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT OR IGNORE INTO main_dish_categories (main_dish_id, category) VALUES (?, ?)`,
|
||||||
|
mainID, c,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSide(db *sql.DB, name string) error {
|
||||||
|
_, err := db.Exec(`INSERT INTO side_dishes (name) VALUES (?)`, name)
|
||||||
|
return taken(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSide(db *sql.DB, id int64, name string) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
`UPDATE side_dishes SET name = ? WHERE id = ? AND deleted_at IS NULL`, name, id)
|
||||||
|
return taken(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete: the row stays so historical log entries keep resolving their
|
||||||
|
// names, but it disappears from the catalog and every picker (PRD §6).
|
||||||
|
func softDeleteMain(db *sql.DB, id int64) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func softDeleteSide(db *sql.DB, id int64) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
`UPDATE side_dishes SET deleted_at = datetime('now') WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sideByID(db *sql.DB, id int64) (*Side, error) {
|
||||||
|
var s Side
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT id, name FROM side_dishes WHERE id = ? AND deleted_at IS NULL`, id,
|
||||||
|
).Scan(&s.ID, &s.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
// HistoryRow is one calendar day: either what was eaten or an unfilled gap.
|
// HistoryRow is one calendar day: either what was eaten or an unfilled gap.
|
||||||
type HistoryRow struct {
|
type HistoryRow struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
|
|||||||
+198
-20
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +51,15 @@ func pickSeparator(v logView) string {
|
|||||||
return "&"
|
return "&"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// categoryLabels lists a dish's categories in Finnish, for the catalog rows.
|
||||||
|
func categoryLabels(d Dish) string {
|
||||||
|
names := make([]string, 0, len(d.Categories))
|
||||||
|
for _, c := range d.Categories {
|
||||||
|
names = append(names, categoryFI[c])
|
||||||
|
}
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
|
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
|
||||||
// after every number except one.
|
// after every number except one.
|
||||||
func countFI(n int, one, many string) string {
|
func countFI(n int, one, many string) string {
|
||||||
@@ -134,28 +144,66 @@ templ dayButton(label string, target, selected, now time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ board(v logView) {
|
templ board(v logView) {
|
||||||
if len(v.Dishes) == 0 && v.Search == "" {
|
<form method="get" action="/" class="searchrow">
|
||||||
<section class="card empty">
|
if !v.Date.Equal(v.Today) {
|
||||||
<p>Ruokalista on tyhjä.</p>
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
<p class="muted small">Tuo ruokia Ruoat-välilehdellä, niin ne ilmestyvät tähän.</p>
|
|
||||||
<a class="primary" href="/ruoat">Siirry ruokiin</a>
|
|
||||||
</section>
|
|
||||||
} else {
|
|
||||||
<form method="get" action="/" class="searchrow">
|
|
||||||
if !v.Date.Equal(v.Today) {
|
|
||||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
|
||||||
}
|
|
||||||
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi" aria-label="Etsi"/>
|
|
||||||
</form>
|
|
||||||
if len(v.Dishes) == 0 {
|
|
||||||
<p class="muted">Ei osumia haulle { v.Search }.</p>
|
|
||||||
}
|
}
|
||||||
|
<input class="filter" type="search" name="haku" value={ v.Search } placeholder="Etsi tai lisää uusi" aria-label="Etsi"/>
|
||||||
|
</form>
|
||||||
|
if len(v.Dishes) > 0 {
|
||||||
<div class="board">
|
<div class="board">
|
||||||
for _, d := range v.Dishes {
|
for _, d := range v.Dishes {
|
||||||
@dishPill(d, v)
|
@dishPill(d, v)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
if len(v.Dishes) == 0 {
|
||||||
|
@quickAddCard(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// quickAddCard turns a search that found nothing into the thing to do next.
|
||||||
|
// Adding a dish here creates it and goes straight to logging it, so the user
|
||||||
|
// never leaves Kirjaa mid-thought.
|
||||||
|
templ quickAddCard(v logView) {
|
||||||
|
<section class="card">
|
||||||
|
if v.Search == "" {
|
||||||
|
<h3>Lisää ensimmäinen ruoka</h3>
|
||||||
|
<p class="muted small">
|
||||||
|
Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruoat-välilehdeltä.
|
||||||
|
</p>
|
||||||
|
} else {
|
||||||
|
<h3>Ei osumia. Lisätäänkö?</h3>
|
||||||
|
}
|
||||||
|
if v.New.Err != "" {
|
||||||
|
<p class="formerr">{ v.New.Err }</p>
|
||||||
|
}
|
||||||
|
<form method="post" action="/lisaa">
|
||||||
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
|
<label class="field">
|
||||||
|
<span>Nimi</span>
|
||||||
|
<input type="text" name="nimi" value={ v.New.Name } autocomplete="off" required/>
|
||||||
|
</label>
|
||||||
|
<div class="field">
|
||||||
|
<span>Kategoriat</span>
|
||||||
|
<div class="chips">
|
||||||
|
@categoryChip("meat", "liha", v.New)
|
||||||
|
@categoryChip("chicken", "kana", v.New)
|
||||||
|
@categoryChip("fish", "kala", v.New)
|
||||||
|
@categoryChip("vegetarian", "kasvis", v.New)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="chip wide">
|
||||||
|
if v.New.HasSides {
|
||||||
|
<input type="checkbox" name="lisukkeita" value="1" checked/>
|
||||||
|
} else {
|
||||||
|
<input type="checkbox" name="lisukkeita" value="1"/>
|
||||||
|
}
|
||||||
|
<span>Tarjoillaan lisukkeiden kanssa</span>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Lisää ja kirjaa</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ dishPill(d Dish, v logView) {
|
templ dishPill(d Dish, v logView) {
|
||||||
@@ -269,23 +317,153 @@ templ historyPage(rows []HistoryRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Ruoat
|
// ---------------------------------------------------------------- Ruoat
|
||||||
templ catalogPage(mains, sides int, report *ImportReport) {
|
templ catalogPage(v catalogView) {
|
||||||
@page("Ruoat — Foodster", "/ruoat") {
|
@page("Ruoat — Foodster", "/ruoat") {
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
<h2>Ruoat</h2>
|
<h2>Ruoat</h2>
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
{ countFI(mains, "pääruoka", "pääruokaa") }, { countFI(sides, "lisuke", "lisuketta") }
|
{ countFI(len(v.Mains), "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<main class="pad">
|
<main class="pad">
|
||||||
if report != nil {
|
if v.Report != nil {
|
||||||
@importReport(report)
|
@importReport(v.Report)
|
||||||
}
|
}
|
||||||
@importForm()
|
@mainForm_(v.Main)
|
||||||
|
<h3 class="sechead">Pääruoat</h3>
|
||||||
|
if len(v.Mains) == 0 {
|
||||||
|
<p class="muted small">Ei vielä pääruokia.</p>
|
||||||
|
}
|
||||||
|
for _, d := range v.Mains {
|
||||||
|
<div class="row">
|
||||||
|
<i class={ "dot", "d-" + d.CategoryKey() }></i>
|
||||||
|
<div class="rowtext">
|
||||||
|
<div class="nm">{ d.Name }</div>
|
||||||
|
<div class="sd">
|
||||||
|
{ categoryLabels(d) }
|
||||||
|
if !d.HasSides {
|
||||||
|
· ei lisukkeita
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@sideForm_(v.Side)
|
||||||
|
<h3 class="sechead">Lisukkeet</h3>
|
||||||
|
if len(v.Sides) == 0 {
|
||||||
|
<p class="muted small">Ei vielä lisukkeita.</p>
|
||||||
|
}
|
||||||
|
for _, s := range v.Sides {
|
||||||
|
<div class="row">
|
||||||
|
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
|
||||||
|
@rowActions("/ruoat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<details class="card">
|
||||||
|
<summary>Tuo ruokia tiedostosta</summary>
|
||||||
|
@importForm()
|
||||||
|
</details>
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ rowActions(editURL string, id int64, kind string) {
|
||||||
|
<div class="rowactions">
|
||||||
|
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa">Muokkaa</a>
|
||||||
|
<form method="post" action="/ruoat/poista">
|
||||||
|
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
|
||||||
|
<input type="hidden" name="tyyppi" value={ kind }/>
|
||||||
|
<button type="submit" class="del" aria-label="Poista">Poista</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ mainForm_(f mainForm) {
|
||||||
|
<section class="card" id="paaruoka">
|
||||||
|
<h3>
|
||||||
|
if f.ID == 0 {
|
||||||
|
Lisää pääruoka
|
||||||
|
} else {
|
||||||
|
Muokkaa pääruokaa
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
if f.Err != "" {
|
||||||
|
<p class="formerr">{ f.Err }</p>
|
||||||
|
}
|
||||||
|
<form method="post" action="/ruoat/paaruoka">
|
||||||
|
if f.ID != 0 {
|
||||||
|
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
||||||
|
}
|
||||||
|
<label class="field">
|
||||||
|
<span>Nimi</span>
|
||||||
|
<input type="text" name="nimi" value={ f.Name } autocomplete="off" required/>
|
||||||
|
</label>
|
||||||
|
<div class="field">
|
||||||
|
<span>Kategoriat</span>
|
||||||
|
<div class="chips">
|
||||||
|
@categoryChip("meat", "liha", f)
|
||||||
|
@categoryChip("chicken", "kana", f)
|
||||||
|
@categoryChip("fish", "kala", f)
|
||||||
|
@categoryChip("vegetarian", "kasvis", f)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="chip wide">
|
||||||
|
if f.HasSides {
|
||||||
|
<input type="checkbox" name="lisukkeita" value="1" checked/>
|
||||||
|
} else {
|
||||||
|
<input type="checkbox" name="lisukkeita" value="1"/>
|
||||||
|
}
|
||||||
|
<span>Tarjoillaan lisukkeiden kanssa</span>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
|
</form>
|
||||||
|
if f.ID != 0 {
|
||||||
|
<a class="ghost" href="/ruoat">Peruuta</a>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ categoryChip(value, label string, f mainForm) {
|
||||||
|
<label class="chip">
|
||||||
|
if f.Categories[value] {
|
||||||
|
<input type="checkbox" name="kategoria" value={ value } checked/>
|
||||||
|
} else {
|
||||||
|
<input type="checkbox" name="kategoria" value={ value }/>
|
||||||
|
}
|
||||||
|
<i class={ "dot", "d-" + categoryFI[value] }></i>
|
||||||
|
<span>{ label }</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ sideForm_(f sideForm) {
|
||||||
|
<section class="card" id="lisuke">
|
||||||
|
<h3>
|
||||||
|
if f.ID == 0 {
|
||||||
|
Lisää lisuke
|
||||||
|
} else {
|
||||||
|
Muokkaa lisuketta
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
if f.Err != "" {
|
||||||
|
<p class="formerr">{ f.Err }</p>
|
||||||
|
}
|
||||||
|
<form method="post" action="/ruoat/lisuke">
|
||||||
|
if f.ID != 0 {
|
||||||
|
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
||||||
|
}
|
||||||
|
<label class="field">
|
||||||
|
<span>Nimi</span>
|
||||||
|
<input type="text" name="nimi" value={ f.Name } autocomplete="off" required/>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
|
</form>
|
||||||
|
if f.ID != 0 {
|
||||||
|
<a class="ghost" href="/ruoat">Peruuta</a>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
templ importForm() {
|
templ importForm() {
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Tuo ruokia</h3>
|
<h3>Tuo ruokia</h3>
|
||||||
|
|||||||
@@ -115,6 +115,60 @@ check "the day is empty again" \
|
|||||||
check "search filters the board" \
|
check "search filters the board" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
|
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
|
||||||
|
|
||||||
|
# ---- adding a dish without leaving Kirjaa --------------------------------
|
||||||
|
|
||||||
|
miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste")
|
||||||
|
check "a search with no hits offers to add it" "$miss" "Ei osumia. Lisätäänkö?"
|
||||||
|
check "the add form is prefilled with the search" "$miss" 'value="Poronkariste"'
|
||||||
|
|
||||||
|
check "quick add goes straight to the sides step" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
||||||
|
-d 'nimi=Poronkariste&kategoria=meat&lisukkeita=1' "http://$addr/lisaa")" \
|
||||||
|
"ruoka="
|
||||||
|
|
||||||
|
check "quick add rejects a dish with no category" \
|
||||||
|
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/lisaa")" \
|
||||||
|
"Valitse vähintään yksi kategoria."
|
||||||
|
|
||||||
|
check "the quick-added dish is on the board" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/")" "Poronkariste"
|
||||||
|
|
||||||
|
# ---- catalog CRUD from the UI -------------------------------------------
|
||||||
|
|
||||||
|
check "adding a main redirects" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||||
|
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruoat/paaruoka")" "303"
|
||||||
|
|
||||||
|
catalog=$(curl -s -u ":$pass" "http://$addr/ruoat")
|
||||||
|
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
|
||||||
|
|
||||||
|
check "a duplicate name is refused" \
|
||||||
|
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruoat/paaruoka")" \
|
||||||
|
"Nimi on jo listalla."
|
||||||
|
|
||||||
|
check "a main with no category is refused" \
|
||||||
|
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruoat/paaruoka")" \
|
||||||
|
"Valitse vähintään yksi kategoria."
|
||||||
|
|
||||||
|
check "a nameless dish is refused" \
|
||||||
|
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruoat/paaruoka")" \
|
||||||
|
"Anna nimi."
|
||||||
|
|
||||||
|
check "adding a side redirects" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||||
|
-d 'nimi=lohkoperunat' "http://$addr/ruoat/lisuke")" "303"
|
||||||
|
|
||||||
|
check "the new side is listed" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "Lohkoperunat"
|
||||||
|
|
||||||
|
uusi=$(printf '%s' "$catalog" | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
|
||||||
|
check "the edit form is prefilled" \
|
||||||
|
"$(curl -s -u ":$pass" "http://$addr/ruoat?muokkaa=$uusi")" "Muokkaa pääruokaa"
|
||||||
|
|
||||||
|
check "deleting a main redirects" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||||
|
-d "id=$uusi&tyyppi=paa" "http://$addr/ruoat/poista")" "303"
|
||||||
|
|
||||||
if [ "$fail" -ne 0 ]; then
|
if [ "$fail" -ne 0 ]; then
|
||||||
echo "smoke: FAILED"
|
echo "smoke: FAILED"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Reference in New Issue
Block a user