This commit is contained in:
@@ -259,7 +259,7 @@ func seedDemoSpecies(ctx context.Context, tx *sql.Tx, gardenID, userID int64, im
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO species (
|
||||
garden_id, category_id, common_name, cultivar, botanical_name,
|
||||
sun_exposure, soil_condition, soil_reaction, spacing_cm, height_cm,
|
||||
sun_exposure, soil_condition, soil_reaction, spacing_cm, height_cm_to,
|
||||
sow_month_from, sow_month_to, planting_month_from, planting_month_to,
|
||||
harvest_month_from, harvest_month_to, notes, created_by, updated_by)
|
||||
SELECT $1, id, $2, $3, $4, 'sunny', 'moist', 'neutral', $5, $6,
|
||||
|
||||
+27
-1
@@ -25,6 +25,14 @@ type speciesInput struct {
|
||||
WinterProtection *string `json:"winter_protection"`
|
||||
SpacingCM *int `json:"spacing_cm"`
|
||||
HeightCM *int `json:"height_cm"`
|
||||
HeightCMFrom *int `json:"height_cm_from"`
|
||||
HeightCMTo *int `json:"height_cm_to"`
|
||||
WidthCMFrom *int `json:"width_cm_from"`
|
||||
WidthCMTo *int `json:"width_cm_to"`
|
||||
ClearHeightCMFrom bool `json:"clear_height_cm_from"`
|
||||
ClearHeightCMTo bool `json:"clear_height_cm_to"`
|
||||
ClearWidthCMFrom bool `json:"clear_width_cm_from"`
|
||||
ClearWidthCMTo bool `json:"clear_width_cm_to"`
|
||||
SowMonthFrom *int `json:"sow_month_from"`
|
||||
SowDayFrom *int `json:"sow_day_from"`
|
||||
ClearSowDayFrom bool `json:"clear_sow_day_from"`
|
||||
@@ -70,7 +78,19 @@ func (input speciesInput) apply(species *storage.Species) {
|
||||
assignStringPointer(input.SoilReaction, &species.SoilReaction)
|
||||
assignStringPointer(input.WinterProtection, &species.WinterProtection)
|
||||
assignIntPointer(input.SpacingCM, &species.SpacingCM)
|
||||
assignIntPointer(input.HeightCM, &species.HeightCM)
|
||||
assignIntPointer(input.HeightCMFrom, &species.HeightCMFrom)
|
||||
if input.HeightCMTo != nil {
|
||||
assignIntPointer(input.HeightCMTo, &species.HeightCMTo)
|
||||
} else {
|
||||
assignIntPointer(input.HeightCM, &species.HeightCMTo)
|
||||
}
|
||||
assignIntPointer(input.WidthCMFrom, &species.WidthCMFrom)
|
||||
assignIntPointer(input.WidthCMTo, &species.WidthCMTo)
|
||||
clearIntPointer(input.ClearHeightCMFrom, &species.HeightCMFrom)
|
||||
clearIntPointer(input.ClearHeightCMTo, &species.HeightCMTo)
|
||||
clearIntPointer(input.ClearWidthCMFrom, &species.WidthCMFrom)
|
||||
clearIntPointer(input.ClearWidthCMTo, &species.WidthCMTo)
|
||||
species.HeightCM = species.HeightCMTo
|
||||
assignIntPointer(input.SowMonthFrom, &species.SowMonthFrom)
|
||||
assignIntPointer(input.SowDayFrom, &species.SowDayFrom)
|
||||
assignIntPointer(input.SowMonthTo, &species.SowMonthTo)
|
||||
@@ -138,6 +158,12 @@ func assignIntPointer(input *int, destination **int) {
|
||||
}
|
||||
}
|
||||
|
||||
func clearIntPointer(clear bool, destination **int) {
|
||||
if clear {
|
||||
*destination = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createSpeciesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gardenID, _ := app.readGardenIDParam(r)
|
||||
var input speciesInput
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
func TestSpeciesInputAppliesAndClearsDimensions(t *testing.T) {
|
||||
heightFrom, heightTo, widthFrom, widthTo := 20, 80, 30, 60
|
||||
species := storage.Species{
|
||||
HeightCMFrom: &heightFrom,
|
||||
HeightCMTo: &heightTo,
|
||||
WidthCMFrom: &widthFrom,
|
||||
WidthCMTo: &widthTo,
|
||||
}
|
||||
newHeightFrom, newWidthTo := 40, 70
|
||||
|
||||
speciesInput{
|
||||
HeightCMFrom: &newHeightFrom,
|
||||
ClearHeightCMTo: true,
|
||||
ClearWidthCMFrom: true,
|
||||
WidthCMTo: &newWidthTo,
|
||||
}.apply(&species)
|
||||
|
||||
if species.HeightCMFrom == nil || *species.HeightCMFrom != 40 {
|
||||
t.Fatalf("height from = %v, want 40", species.HeightCMFrom)
|
||||
}
|
||||
if species.HeightCMTo != nil {
|
||||
t.Fatalf("height to = %v, want nil", species.HeightCMTo)
|
||||
}
|
||||
if species.WidthCMFrom != nil {
|
||||
t.Fatalf("width from = %v, want nil", species.WidthCMFrom)
|
||||
}
|
||||
if species.WidthCMTo == nil || *species.WidthCMTo != 70 {
|
||||
t.Fatalf("width to = %v, want 70", species.WidthCMTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeciesInputAcceptsDeprecatedHeight(t *testing.T) {
|
||||
height := 90
|
||||
var species storage.Species
|
||||
|
||||
speciesInput{HeightCM: &height}.apply(&species)
|
||||
|
||||
if species.HeightCMTo == nil || *species.HeightCMTo != height {
|
||||
t.Fatalf("height to = %v, want %d", species.HeightCMTo, height)
|
||||
}
|
||||
if species.HeightCM == nil || *species.HeightCM != height {
|
||||
t.Fatalf("deprecated height alias = %v, want %d", species.HeightCM, height)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE species
|
||||
DROP COLUMN width_cm_to,
|
||||
DROP COLUMN width_cm_from,
|
||||
DROP COLUMN height_cm_from;
|
||||
|
||||
ALTER TABLE species RENAME COLUMN height_cm_to TO height_cm;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE species RENAME COLUMN height_cm TO height_cm_to;
|
||||
|
||||
ALTER TABLE species
|
||||
ADD COLUMN height_cm_from integer,
|
||||
ADD COLUMN width_cm_from integer,
|
||||
ADD COLUMN width_cm_to integer;
|
||||
@@ -13,7 +13,8 @@ type SpeciesModel struct{ DB *sql.DB }
|
||||
|
||||
const speciesColumns = `s.id, s.garden_id, s.common_name, s.cultivar, s.botanical_name,
|
||||
s.category_id, COALESCE(c.name, ''), s.sun_exposure, s.soil_condition, s.soil_reaction,
|
||||
s.winter_protection, s.spacing_cm, s.height_cm,
|
||||
s.winter_protection, s.spacing_cm, s.height_cm_from, s.height_cm_to,
|
||||
s.width_cm_from, s.width_cm_to,
|
||||
s.sow_month_from, s.sow_day_from, s.sow_month_to, s.sow_day_to,
|
||||
s.planting_month_from, s.planting_day_from, s.planting_month_to, s.planting_day_to,
|
||||
s.harvest_month_from, s.harvest_day_from, s.harvest_month_to, s.harvest_day_to,
|
||||
@@ -26,19 +27,26 @@ func scanSpecies(s scanner) (storage.Species, error) {
|
||||
&species.ID, &species.GardenID, &species.CommonName, &species.Cultivar,
|
||||
&species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition,
|
||||
&species.SoilReaction, &species.WinterProtection, &species.SpacingCM,
|
||||
&species.HeightCM, &species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo,
|
||||
&species.HeightCMFrom, &species.HeightCMTo, &species.WidthCMFrom, &species.WidthCMTo,
|
||||
&species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo,
|
||||
&species.SowDayTo, &species.PlantingMonthFrom, &species.PlantingDayFrom, &species.PlantingMonthTo, &species.PlantingDayTo, &species.HarvestMonthFrom, &species.HarvestDayFrom,
|
||||
&species.HarvestMonthTo, &species.HarvestDayTo, &species.Notes, &species.Attributes, &species.ImageData, &species.ImageID,
|
||||
&species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy,
|
||||
)
|
||||
species.HeightCM = species.HeightCMTo
|
||||
return species, err
|
||||
}
|
||||
|
||||
func speciesArgs(species storage.Species) []any {
|
||||
heightTo := species.HeightCMTo
|
||||
if heightTo == nil {
|
||||
heightTo = species.HeightCM
|
||||
}
|
||||
return []any{
|
||||
species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName,
|
||||
species.CategoryID, species.SunExposure, species.SoilCondition, species.SoilReaction,
|
||||
species.WinterProtection, species.SpacingCM, species.HeightCM,
|
||||
species.WinterProtection, species.SpacingCM, species.HeightCMFrom, heightTo,
|
||||
species.WidthCMFrom, species.WidthCMTo,
|
||||
species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo,
|
||||
species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo,
|
||||
species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo,
|
||||
@@ -53,13 +61,15 @@ func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) {
|
||||
query := `
|
||||
INSERT INTO species (
|
||||
garden_id, common_name, cultivar, botanical_name, category_id, sun_exposure,
|
||||
soil_condition, soil_reaction, winter_protection, spacing_cm, height_cm,
|
||||
soil_condition, soil_reaction, winter_protection, spacing_cm,
|
||||
height_cm_from, height_cm_to, width_cm_from, width_cm_to,
|
||||
sow_month_from, sow_day_from, sow_month_to, sow_day_to,
|
||||
planting_month_from, planting_day_from, planting_month_to, planting_day_to,
|
||||
harvest_month_from, harvest_day_from, harvest_month_to, harvest_day_to,
|
||||
notes, attributes, image_data, image_id, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, '', $26, $27, $28)
|
||||
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24,
|
||||
$25, $26, $27, $28, '', $29, $30, $31)
|
||||
RETURNING id, created_at, updated_at, version`
|
||||
err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan(
|
||||
&species.ID, &species.CreatedAt, &species.UpdatedAt, &species.Version,
|
||||
@@ -67,6 +77,7 @@ func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) {
|
||||
if err != nil {
|
||||
return storage.Species{}, recordError(err)
|
||||
}
|
||||
species.HeightCM = species.HeightCMTo
|
||||
return species, nil
|
||||
}
|
||||
|
||||
@@ -78,6 +89,7 @@ func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) {
|
||||
if err != nil {
|
||||
return storage.Species{}, recordError(err)
|
||||
}
|
||||
species.HeightCM = species.HeightCMTo
|
||||
return species, nil
|
||||
}
|
||||
|
||||
@@ -113,18 +125,18 @@ func (m SpeciesModel) Update(gardenID int, species storage.Species) (storage.Spe
|
||||
// created_by is immutable and is intentionally not part of the UPDATE.
|
||||
// Remove it from the shared insert argument list so PostgreSQL does not
|
||||
// receive an unused, untyped parameter between image_id and updated_by.
|
||||
args = append(args[:26], args[27])
|
||||
args = append(args[:29], args[30])
|
||||
args = append(args, gardenID, species.ID, species.Version)
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3,
|
||||
botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7,
|
||||
soil_reaction = $8, winter_protection = $9, spacing_cm = $10,
|
||||
height_cm = $11, sow_month_from = $12, sow_day_from = $13,
|
||||
sow_month_to = $14, sow_day_to = $15, planting_month_from=$16,
|
||||
planting_day_from=$17, planting_month_to=$18, planting_day_to=$19,
|
||||
harvest_month_from = $20, harvest_day_from = $21, harvest_month_to = $22, harvest_day_to = $23,
|
||||
notes = $24, attributes = $25, image_data = '', image_id = $26, updated_by = $27, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE garden_id IS NOT DISTINCT FROM NULLIF($28, 0) AND id = $29 AND version = $30
|
||||
height_cm_from = $11, height_cm_to = $12, width_cm_from = $13, width_cm_to = $14,
|
||||
sow_month_from = $15, sow_day_from = $16, sow_month_to = $17, sow_day_to = $18,
|
||||
planting_month_from=$19, planting_day_from=$20, planting_month_to=$21, planting_day_to=$22,
|
||||
harvest_month_from = $23, harvest_day_from = $24, harvest_month_to = $25, harvest_day_to = $26,
|
||||
notes = $27, attributes = $28, image_data = '', image_id = $29, updated_by = $30, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE garden_id IS NOT DISTINCT FROM NULLIF($31, 0) AND id = $32 AND version = $33
|
||||
RETURNING updated_at, version`, args...,
|
||||
).Scan(&species.UpdatedAt, &species.Version)
|
||||
if err != nil {
|
||||
|
||||
+31
-13
@@ -19,19 +19,24 @@ type SpeciesModelInterface interface {
|
||||
|
||||
// Species contains reusable botanical and cultivation master data.
|
||||
type Species struct {
|
||||
ID int `json:"id"`
|
||||
GardenID *int `json:"garden_id,omitempty"`
|
||||
CommonName string `json:"common_name"`
|
||||
Cultivar string `json:"cultivar"`
|
||||
BotanicalName string `json:"botanical_name"`
|
||||
CategoryID *int `json:"category_id,omitempty"`
|
||||
Category string `json:"category"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||
ID int `json:"id"`
|
||||
GardenID *int `json:"garden_id,omitempty"`
|
||||
CommonName string `json:"common_name"`
|
||||
Cultivar string `json:"cultivar"`
|
||||
BotanicalName string `json:"botanical_name"`
|
||||
CategoryID *int `json:"category_id,omitempty"`
|
||||
Category string `json:"category"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||
// HeightCM is retained as a deprecated API alias for HeightCMTo.
|
||||
HeightCM *int `json:"height_cm,omitempty"`
|
||||
HeightCMFrom *int `json:"height_cm_from,omitempty"`
|
||||
HeightCMTo *int `json:"height_cm_to,omitempty"`
|
||||
WidthCMFrom *int `json:"width_cm_from,omitempty"`
|
||||
WidthCMTo *int `json:"width_cm_to,omitempty"`
|
||||
SowMonthFrom *int `json:"sow_month_from,omitempty"`
|
||||
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
||||
SowMonthTo *int `json:"sow_month_to,omitempty"`
|
||||
@@ -72,7 +77,12 @@ func ValidateSpecies(v *validate.Validator, species Species) {
|
||||
validateOptionalEnum(v, "soil_condition", species.SoilCondition, "dry", "moist", "boggy")
|
||||
validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral")
|
||||
validateOptionalPositive(v, "spacing_cm", species.SpacingCM)
|
||||
validateOptionalPositive(v, "height_cm", species.HeightCM)
|
||||
heightTo := species.HeightCMTo
|
||||
if heightTo == nil {
|
||||
heightTo = species.HeightCM
|
||||
}
|
||||
validateOptionalRange(v, "height_cm", species.HeightCMFrom, heightTo)
|
||||
validateOptionalRange(v, "width_cm", species.WidthCMFrom, species.WidthCMTo)
|
||||
validateOptionalCalendarPart(v, "sow_month_from", species.SowMonthFrom, 12)
|
||||
validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31)
|
||||
validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12)
|
||||
@@ -99,6 +109,14 @@ func validateOptionalPositive(v *validate.Validator, field string, value *int) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateOptionalRange(v *validate.Validator, field string, from, to *int) {
|
||||
validateOptionalPositive(v, field+"_from", from)
|
||||
validateOptionalPositive(v, field+"_to", to)
|
||||
if from != nil && to != nil {
|
||||
v.Check(*from <= *to, field+"_from", "must not be greater than "+field+"_to")
|
||||
}
|
||||
}
|
||||
|
||||
func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) {
|
||||
if value != nil {
|
||||
v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/validate"
|
||||
)
|
||||
|
||||
func TestValidateSpeciesDimensions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
species Species
|
||||
wantField string
|
||||
}{
|
||||
{name: "valid ranges", species: Species{CommonName: "Rose", HeightCMFrom: intPointer(40), HeightCMTo: intPointer(80), WidthCMFrom: intPointer(30), WidthCMTo: intPointer(60)}},
|
||||
{name: "height starts above end", species: Species{CommonName: "Rose", HeightCMFrom: intPointer(81), HeightCMTo: intPointer(80)}, wantField: "height_cm_from"},
|
||||
{name: "width starts above end", species: Species{CommonName: "Rose", WidthCMFrom: intPointer(61), WidthCMTo: intPointer(60)}, wantField: "width_cm_from"},
|
||||
{name: "non-positive height", species: Species{CommonName: "Rose", HeightCMTo: intPointer(0)}, wantField: "height_cm_to"},
|
||||
{name: "deprecated height alias", species: Species{CommonName: "Rose", HeightCM: intPointer(80)}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
v := validate.New()
|
||||
ValidateSpecies(v, tt.species)
|
||||
_, hasError := v.Errors[tt.wantField]
|
||||
if tt.wantField == "" && !v.Valid() {
|
||||
t.Fatalf("unexpected validation errors: %v", v.Errors)
|
||||
}
|
||||
if tt.wantField != "" && !hasError {
|
||||
t.Fatalf("expected validation error for %s, got %v", tt.wantField, v.Errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int {
|
||||
return &value
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestGardenAndSpeciesCanBeEdited(t *testing.T) {
|
||||
if gardenResponse.Code != http.StatusSeeOther || gardenInput.Name == nil || *gardenInput.Name != "Neuer Garten" {
|
||||
t.Fatalf("garden edit: status=%d input=%+v", gardenResponse.Code, gardenInput)
|
||||
}
|
||||
speciesForm := url.Values{"common_name": {"Neue Rose"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}}
|
||||
speciesForm := url.Values{"common_name": {"Neue Rose"}, "height_cm_from": {"40"}, "height_cm_to": {"80"}, "width_cm_to": {"60"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}}
|
||||
speciesRequest := httptest.NewRequest(http.MethodPost, "/g/3/species/edit/7", strings.NewReader(speciesForm.Encode()))
|
||||
speciesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
speciesRequest = taskWebRequest(speciesRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "speciesID", Value: "7"}})
|
||||
@@ -103,6 +103,12 @@ func TestGardenAndSpeciesCanBeEdited(t *testing.T) {
|
||||
if speciesResponse.Code != http.StatusSeeOther || speciesInput.CommonName == nil || *speciesInput.CommonName != "Neue Rose" || !speciesInput.ClearSowRange {
|
||||
t.Fatalf("species edit: status=%d input=%+v", speciesResponse.Code, speciesInput)
|
||||
}
|
||||
if speciesInput.HeightCMFrom == nil || *speciesInput.HeightCMFrom != 40 || speciesInput.HeightCMTo == nil || *speciesInput.HeightCMTo != 80 || speciesInput.WidthCMTo == nil || *speciesInput.WidthCMTo != 60 {
|
||||
t.Fatalf("species dimensions were not submitted: %+v", speciesInput)
|
||||
}
|
||||
if speciesInput.ClearHeightCMFrom || speciesInput.ClearHeightCMTo || !speciesInput.ClearWidthCMFrom || speciesInput.ClearWidthCMTo {
|
||||
t.Fatalf("species dimension clear flags are incorrect: %+v", speciesInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenCanBeDeleted(t *testing.T) {
|
||||
|
||||
+25
-14
@@ -22,7 +22,10 @@ type speciesForm struct {
|
||||
SoilReaction string `form:"soil_reaction"`
|
||||
WinterProtection string `form:"winter_protection"`
|
||||
SpacingCM int `form:"spacing_cm"`
|
||||
HeightCM int `form:"height_cm"`
|
||||
HeightCMFrom int `form:"height_cm_from"`
|
||||
HeightCMTo int `form:"height_cm_to"`
|
||||
WidthCMFrom int `form:"width_cm_from"`
|
||||
WidthCMTo int `form:"width_cm_to"`
|
||||
Notes string `form:"notes"`
|
||||
ImageData string `form:"image_data"`
|
||||
ImageID int `form:"image_id"`
|
||||
@@ -133,7 +136,13 @@ func speciesFormFromSpecies(value client.Species) speciesForm {
|
||||
form.WinterProtection = *value.WinterProtection
|
||||
}
|
||||
copyOptionalInt(value.SpacingCM, &form.SpacingCM)
|
||||
copyOptionalInt(value.HeightCM, &form.HeightCM)
|
||||
copyOptionalInt(value.HeightCMFrom, &form.HeightCMFrom)
|
||||
copyOptionalInt(value.HeightCMTo, &form.HeightCMTo)
|
||||
if value.HeightCMTo == nil {
|
||||
copyOptionalInt(value.HeightCM, &form.HeightCMTo)
|
||||
}
|
||||
copyOptionalInt(value.WidthCMFrom, &form.WidthCMFrom)
|
||||
copyOptionalInt(value.WidthCMTo, &form.WidthCMTo)
|
||||
copyOptionalInt(value.CategoryID, &form.CategoryID)
|
||||
copyOptionalInt(value.SowMonthFrom, &form.SowMonthFrom)
|
||||
copyOptionalInt(value.SowDayFrom, &form.SowDayFrom)
|
||||
@@ -219,9 +228,10 @@ func speciesInput(form speciesForm) client.SpeciesInput {
|
||||
if form.SpacingCM > 0 {
|
||||
input.SpacingCM = &form.SpacingCM
|
||||
}
|
||||
if form.HeightCM > 0 {
|
||||
input.HeightCM = &form.HeightCM
|
||||
}
|
||||
input.HeightCMFrom, input.HeightCMTo = optionalPositive(form.HeightCMFrom), optionalPositive(form.HeightCMTo)
|
||||
input.WidthCMFrom, input.WidthCMTo = optionalPositive(form.WidthCMFrom), optionalPositive(form.WidthCMTo)
|
||||
input.ClearHeightCMFrom, input.ClearHeightCMTo = form.HeightCMFrom == 0, form.HeightCMTo == 0
|
||||
input.ClearWidthCMFrom, input.ClearWidthCMTo = form.WidthCMFrom == 0, form.WidthCMTo == 0
|
||||
if json.Valid([]byte(form.Attributes)) {
|
||||
input.Attributes = json.RawMessage(form.Attributes)
|
||||
}
|
||||
@@ -230,18 +240,12 @@ func speciesInput(form speciesForm) client.SpeciesInput {
|
||||
} else {
|
||||
input.ClearCategoryID = true
|
||||
}
|
||||
assignPositive := func(value int) *int {
|
||||
if value > 0 {
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
form.SowMonthTo, form.SowDayTo = rangeEnd(form.SowMonthFrom, form.SowDayFrom, form.SowDuration, form.SowDurationUnit)
|
||||
form.PlantingMonthTo, form.PlantingDayTo = rangeEnd(form.PlantingMonthFrom, form.PlantingDayFrom, form.PlantingDuration, form.PlantingDurationUnit)
|
||||
form.HarvestMonthTo, form.HarvestDayTo = rangeEnd(form.HarvestMonthFrom, form.HarvestDayFrom, form.HarvestDuration, form.HarvestDurationUnit)
|
||||
input.SowMonthFrom, input.SowDayFrom, input.SowMonthTo, input.SowDayTo = assignPositive(form.SowMonthFrom), assignPositive(form.SowDayFrom), assignPositive(form.SowMonthTo), assignPositive(form.SowDayTo)
|
||||
input.PlantingMonthFrom, input.PlantingDayFrom, input.PlantingMonthTo, input.PlantingDayTo = assignPositive(form.PlantingMonthFrom), assignPositive(form.PlantingDayFrom), assignPositive(form.PlantingMonthTo), assignPositive(form.PlantingDayTo)
|
||||
input.HarvestMonthFrom, input.HarvestDayFrom, input.HarvestMonthTo, input.HarvestDayTo = assignPositive(form.HarvestMonthFrom), assignPositive(form.HarvestDayFrom), assignPositive(form.HarvestMonthTo), assignPositive(form.HarvestDayTo)
|
||||
input.SowMonthFrom, input.SowDayFrom, input.SowMonthTo, input.SowDayTo = optionalPositive(form.SowMonthFrom), optionalPositive(form.SowDayFrom), optionalPositive(form.SowMonthTo), optionalPositive(form.SowDayTo)
|
||||
input.PlantingMonthFrom, input.PlantingDayFrom, input.PlantingMonthTo, input.PlantingDayTo = optionalPositive(form.PlantingMonthFrom), optionalPositive(form.PlantingDayFrom), optionalPositive(form.PlantingMonthTo), optionalPositive(form.PlantingDayTo)
|
||||
input.HarvestMonthFrom, input.HarvestDayFrom, input.HarvestMonthTo, input.HarvestDayTo = optionalPositive(form.HarvestMonthFrom), optionalPositive(form.HarvestDayFrom), optionalPositive(form.HarvestMonthTo), optionalPositive(form.HarvestDayTo)
|
||||
input.ClearSowRange = form.SowMonthFrom == 0 && form.SowMonthTo == 0
|
||||
input.ClearPlantingRange = form.PlantingMonthFrom == 0 && form.PlantingMonthTo == 0
|
||||
input.ClearHarvestRange = form.HarvestMonthFrom == 0 && form.HarvestMonthTo == 0
|
||||
@@ -251,6 +255,13 @@ func speciesInput(form speciesForm) client.SpeciesInput {
|
||||
return input
|
||||
}
|
||||
|
||||
func optionalPositive(value int) *int {
|
||||
if value > 0 {
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rangeEnd(month, day, duration int, unit string) (int, int) {
|
||||
if month < 1 {
|
||||
return 0, 0
|
||||
|
||||
@@ -165,6 +165,10 @@ button, .button { display: inline-block; width: auto; padding: .75rem 1rem; colo
|
||||
.danger:hover, .template-remove:hover { background: #7f291f; }
|
||||
.species-template-add { width: 2.5rem; height: 2.5rem; margin-top: .75rem; margin-left: auto; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1rem; }
|
||||
.dimension-range { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: .55rem; }
|
||||
.dimension-range:focus-within { border-color: var(--leaf); box-shadow: 0 0 0 .15rem var(--leaf-light); }
|
||||
.dimension-range input { min-width: 0; border: 0; border-radius: 0; outline: 0; }
|
||||
.dimension-range > span { color: var(--muted); font-weight: 400; }
|
||||
.filter-bar { width: 100%; max-width: none; margin-bottom: 2rem; }
|
||||
.image-library-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1rem; }
|
||||
.image-library-item { margin:0; overflow:hidden; padding:0; }
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
{{range .SpeciesCategories}}{{if or .Active (eqInt $form.CategoryID .ID)}}<option value='{{.ID}}' {{if eqInt $form.CategoryID .ID}}selected{{end}}>{{.Name}}{{with lifecycleName .Lifecycle}} ({{.}}){{end}}{{if not .Active}} (inaktiv){{end}}</option>{{end}}{{end}}
|
||||
</select>
|
||||
{{with index $form.Errors "category_id"}}<p class='field-error'>{{.}}</p>{{end}}
|
||||
<div class='form-grid'><label>Licht<select name='sun_exposure'><option value=''>Nicht angegeben</option><option value='sunny' {{if eqString $form.SunExposure "sunny"}}selected{{end}}>Sonnig</option><option value='partial_shade' {{if eqString $form.SunExposure "partial_shade"}}selected{{end}}>Halbschattig</option><option value='shade' {{if eqString $form.SunExposure "shade"}}selected{{end}}>Schattig</option></select></label><label>Bodenbeschaffenheit<select name='soil_condition'><option value=''>Nicht angegeben</option><option value='dry' {{if eqString $form.SoilCondition "dry"}}selected{{end}}>Trocken</option><option value='moist' {{if eqString $form.SoilCondition "moist"}}selected{{end}}>Feucht</option><option value='boggy' {{if eqString $form.SoilCondition "boggy"}}selected{{end}}>Sumpfig</option></select></label><label>Bodenreaktion<select name='soil_reaction'><option value=''>Nicht angegeben</option><option value='alkaline' {{if eqString $form.SoilReaction "alkaline"}}selected{{end}}>Basisch</option><option value='acidic' {{if eqString $form.SoilReaction "acidic"}}selected{{end}}>Sauer</option><option value='neutral' {{if eqString $form.SoilReaction "neutral"}}selected{{end}}>Neutral</option></select></label><label>Winterschutz<input name='winter_protection' value='{{$form.WinterProtection}}'></label><label>Pflanzabstand in cm<input type='number' min='1' name='spacing_cm' value='{{if $form.SpacingCM}}{{$form.SpacingCM}}{{end}}'></label><label>Höhe in cm<input type='number' min='1' name='height_cm' value='{{if $form.HeightCM}}{{$form.HeightCM}}{{end}}'></label></div>
|
||||
<div class='form-grid'><label>Licht<select name='sun_exposure'><option value=''>Nicht angegeben</option><option value='sunny' {{if eqString $form.SunExposure "sunny"}}selected{{end}}>Sonnig</option><option value='partial_shade' {{if eqString $form.SunExposure "partial_shade"}}selected{{end}}>Halbschattig</option><option value='shade' {{if eqString $form.SunExposure "shade"}}selected{{end}}>Schattig</option></select></label><label>Bodenbeschaffenheit<select name='soil_condition'><option value=''>Nicht angegeben</option><option value='dry' {{if eqString $form.SoilCondition "dry"}}selected{{end}}>Trocken</option><option value='moist' {{if eqString $form.SoilCondition "moist"}}selected{{end}}>Feucht</option><option value='boggy' {{if eqString $form.SoilCondition "boggy"}}selected{{end}}>Sumpfig</option></select></label><label>Bodenreaktion<select name='soil_reaction'><option value=''>Nicht angegeben</option><option value='alkaline' {{if eqString $form.SoilReaction "alkaline"}}selected{{end}}>Basisch</option><option value='acidic' {{if eqString $form.SoilReaction "acidic"}}selected{{end}}>Sauer</option><option value='neutral' {{if eqString $form.SoilReaction "neutral"}}selected{{end}}>Neutral</option></select></label><label>Winterschutz<input name='winter_protection' value='{{$form.WinterProtection}}'></label><label>Pflanzabstand in cm<input type='number' min='1' name='spacing_cm' value='{{if $form.SpacingCM}}{{$form.SpacingCM}}{{end}}'></label><label>Höhe in cm<span class='dimension-range'><input type='number' min='1' name='height_cm_from' aria-label='Höhe von' placeholder='von' value='{{if $form.HeightCMFrom}}{{$form.HeightCMFrom}}{{end}}'><span aria-hidden='true'>–</span><input type='number' min='1' name='height_cm_to' aria-label='Höhe bis' placeholder='bis' value='{{if $form.HeightCMTo}}{{$form.HeightCMTo}}{{end}}'></span>{{with index $form.Errors "height_cm_from"}}<span class='field-error'>{{.}}</span>{{end}}{{with index $form.Errors "height_cm_to"}}<span class='field-error'>{{.}}</span>{{end}}</label><label>Breite in cm<span class='dimension-range'><input type='number' min='1' name='width_cm_from' aria-label='Breite von' placeholder='von' value='{{if $form.WidthCMFrom}}{{$form.WidthCMFrom}}{{end}}'><span aria-hidden='true'>–</span><input type='number' min='1' name='width_cm_to' aria-label='Breite bis' placeholder='bis' value='{{if $form.WidthCMTo}}{{$form.WidthCMTo}}{{end}}'></span>{{with index $form.Errors "width_cm_from"}}<span class='field-error'>{{.}}</span>{{end}}{{with index $form.Errors "width_cm_to"}}<span class='field-error'>{{.}}</span>{{end}}</label></div>
|
||||
{{template "season_range" (dict "Legend" "Aussaat" "Prefix" "sow" "Month" $form.SowMonthFrom "Day" $form.SowDayFrom "Duration" $form.SowDuration "Unit" $form.SowDurationUnit)}}
|
||||
{{template "season_range" (dict "Legend" "Pflanzzeit" "Prefix" "planting" "Month" $form.PlantingMonthFrom "Day" $form.PlantingDayFrom "Duration" $form.PlantingDuration "Unit" $form.PlantingDurationUnit)}}
|
||||
{{template "season_range" (dict "Legend" "Ernte" "Prefix" "harvest" "Month" $form.HarvestMonthFrom "Day" $form.HarvestDayFrom "Duration" $form.HarvestDuration "Unit" $form.HarvestDurationUnit)}}
|
||||
|
||||
@@ -371,11 +371,19 @@ func TestEmptyOptionalSpeciesDimensionsDoNotBlockSubmit(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
app.render(response, http.StatusOK, "species_form.tmpl", data)
|
||||
body := response.Body.String()
|
||||
for _, field := range []string{"spacing_cm", "height_cm"} {
|
||||
if !strings.Contains(body, "name='"+field+"' value=''") {
|
||||
for _, field := range []string{"spacing_cm", "height_cm_from", "height_cm_to", "width_cm_from", "width_cm_to"} {
|
||||
fieldStart := strings.Index(body, "name='"+field+"'")
|
||||
fieldEnd := -1
|
||||
if fieldStart >= 0 {
|
||||
fieldEnd = strings.Index(body[fieldStart:], ">")
|
||||
}
|
||||
if fieldStart < 0 || fieldEnd < 0 || !strings.Contains(body[fieldStart:fieldStart+fieldEnd], "value=''") {
|
||||
t.Errorf("optional field %s should render empty: %s", field, body)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(body, "class='dimension-range'"); got != 2 {
|
||||
t.Errorf("dimension ranges: got %d composite controls, want 2: %s", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDueFormatsWindows(t *testing.T) {
|
||||
|
||||
+38
-24
@@ -177,19 +177,24 @@ type UpdateGardenInput struct {
|
||||
|
||||
// Species describes global or garden-specific plant master data.
|
||||
type Species struct {
|
||||
ID int `json:"id"`
|
||||
GardenID *int `json:"garden_id,omitempty"`
|
||||
CommonName string `json:"common_name"`
|
||||
Cultivar string `json:"cultivar"`
|
||||
BotanicalName string `json:"botanical_name"`
|
||||
CategoryID *int `json:"category_id,omitempty"`
|
||||
Category string `json:"category"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||
ID int `json:"id"`
|
||||
GardenID *int `json:"garden_id,omitempty"`
|
||||
CommonName string `json:"common_name"`
|
||||
Cultivar string `json:"cultivar"`
|
||||
BotanicalName string `json:"botanical_name"`
|
||||
CategoryID *int `json:"category_id,omitempty"`
|
||||
Category string `json:"category"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||
// HeightCM is retained as a deprecated API alias for HeightCMTo.
|
||||
HeightCM *int `json:"height_cm,omitempty"`
|
||||
HeightCMFrom *int `json:"height_cm_from,omitempty"`
|
||||
HeightCMTo *int `json:"height_cm_to,omitempty"`
|
||||
WidthCMFrom *int `json:"width_cm_from,omitempty"`
|
||||
WidthCMTo *int `json:"width_cm_to,omitempty"`
|
||||
SowMonthFrom *int `json:"sow_month_from,omitempty"`
|
||||
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
||||
SowMonthTo *int `json:"sow_month_to,omitempty"`
|
||||
@@ -216,19 +221,28 @@ type Species struct {
|
||||
|
||||
// SpeciesInput contains optional species fields used for creation and updates.
|
||||
type SpeciesInput struct {
|
||||
Global bool `json:"global,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CommonName *string `json:"common_name,omitempty"`
|
||||
Cultivar *string `json:"cultivar,omitempty"`
|
||||
BotanicalName *string `json:"botanical_name,omitempty"`
|
||||
CategoryID *int `json:"category_id,omitempty"`
|
||||
ClearCategoryID bool `json:"clear_category_id,omitempty"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||
Global bool `json:"global,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CommonName *string `json:"common_name,omitempty"`
|
||||
Cultivar *string `json:"cultivar,omitempty"`
|
||||
BotanicalName *string `json:"botanical_name,omitempty"`
|
||||
CategoryID *int `json:"category_id,omitempty"`
|
||||
ClearCategoryID bool `json:"clear_category_id,omitempty"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||
// HeightCM is retained for compatibility; new code should use HeightCMTo.
|
||||
HeightCM *int `json:"height_cm,omitempty"`
|
||||
HeightCMFrom *int `json:"height_cm_from,omitempty"`
|
||||
HeightCMTo *int `json:"height_cm_to,omitempty"`
|
||||
WidthCMFrom *int `json:"width_cm_from,omitempty"`
|
||||
WidthCMTo *int `json:"width_cm_to,omitempty"`
|
||||
ClearHeightCMFrom bool `json:"clear_height_cm_from,omitempty"`
|
||||
ClearHeightCMTo bool `json:"clear_height_cm_to,omitempty"`
|
||||
ClearWidthCMFrom bool `json:"clear_width_cm_from,omitempty"`
|
||||
ClearWidthCMTo bool `json:"clear_width_cm_to,omitempty"`
|
||||
SowMonthFrom *int `json:"sow_month_from,omitempty"`
|
||||
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
||||
ClearSowDayFrom bool `json:"clear_sow_day_from,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user