Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c4abe23c6 | ||
|
|
5bdaf21828 | ||
|
|
8552f1d152 |
@@ -64,8 +64,6 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
|
|||||||
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
|
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
|
||||||
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
||||||
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
||||||
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect
|
|
||||||
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
|
|
||||||
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
||||||
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
||||||
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
||||||
@@ -74,8 +72,6 @@ POST /agent/chat ← SSE: step events, then the finished turn (edit
|
|||||||
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
||||||
GET /capabilities ← what this instance can do, so the UI offers only what works
|
GET /capabilities ← what this instance can do, so the UI offers only what works
|
||||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||||
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
|
|
||||||
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
|
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
|
||||||
|
|||||||
@@ -119,11 +119,6 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
objects.PATCH("/:id", h.updateObject)
|
objects.PATCH("/:id", h.updateObject)
|
||||||
objects.DELETE("/:id", h.deleteObject)
|
objects.DELETE("/:id", h.deleteObject)
|
||||||
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
|
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
|
||||||
// Bulk ops. These wrap the same service methods the agent tools call, so an
|
|
||||||
// instance with no model configured still gets the most valuable operation in
|
|
||||||
// the app — and so "clear bed" is ONE change set rather than one per plop.
|
|
||||||
objects.POST("/:id/fill", h.fillObject)
|
|
||||||
objects.POST("/:id/clear", h.clearObject)
|
|
||||||
|
|
||||||
// Plantings ("plops") are addressed by their own id; the service resolves the
|
// Plantings ("plops") are addressed by their own id; the service resolves the
|
||||||
// owning object/garden for the permission check.
|
// owning object/garden for the permission check.
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bulk operations on a plantable object (#82): fill a region with one plant, and
|
|
||||||
// clear everything out of it.
|
|
||||||
//
|
|
||||||
// These were reachable only through the agent toolbox until now, which meant the
|
|
||||||
// most valuable bulk operation in a garden planner — and the one carrying the
|
|
||||||
// most carefully reasoned geometry in the codebase — did not exist at all on an
|
|
||||||
// instance with no model configured. They are thin adapters over the same
|
|
||||||
// service methods `internal/agent/tools.go` calls, so the permission checks and
|
|
||||||
// the one-change-set-per-operation guarantee come along unchanged.
|
|
||||||
|
|
||||||
// fillRect is an explicit rectangle in the object's local frame, the alternative
|
|
||||||
// to a compass name. A named type (not an inline anonymous struct) to match the
|
|
||||||
// rest of internal/api and so it can carry its own validity check.
|
|
||||||
type fillRect struct {
|
|
||||||
MinX float64 `json:"minXCm"`
|
|
||||||
MinY float64 `json:"minYCm"`
|
|
||||||
MaxX float64 `json:"maxXCm"`
|
|
||||||
MaxY float64 `json:"maxYCm"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// degenerate reports whether the rect encloses no area. Such a rect (including
|
|
||||||
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
|
|
||||||
// and plant a single plop at the object's centre — a surprising result for what
|
|
||||||
// is really malformed input.
|
|
||||||
func (r fillRect) degenerate() bool {
|
|
||||||
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
|
|
||||||
}
|
|
||||||
|
|
||||||
// objectFillRequest is the body for POST /objects/:id/fill.
|
|
||||||
//
|
|
||||||
// A region is given EITHER by compass name ("ne", "south half", "all") or as an
|
|
||||||
// explicit rect in the object's local frame. The named form is what a person
|
|
||||||
// means and what the agent uses; the rect is for a future drag-a-box affordance.
|
|
||||||
// Exactly one must be supplied — accepting both and silently preferring one
|
|
||||||
// would make a client bug look like a geometry bug.
|
|
||||||
type objectFillRequest struct {
|
|
||||||
PlantID int64 `json:"plantId" binding:"required"`
|
|
||||||
Region string `json:"region"`
|
|
||||||
Rect *fillRect `json:"rect"`
|
|
||||||
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
|
|
||||||
// without editing the catalog entry.
|
|
||||||
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *handlers) fillObject(c *gin.Context) {
|
|
||||||
id, ok := parseIDParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req objectFillRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
named, hasRect := req.Region != "", req.Rect != nil
|
|
||||||
if named == hasRect {
|
|
||||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT",
|
|
||||||
`supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
actor := mustActor(c).ID
|
|
||||||
var (
|
|
||||||
created []domain.Planting
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if rect := req.Rect; rect != nil {
|
|
||||||
// Reject a zero-area rect here rather than let it plant one stray plop.
|
|
||||||
// (Binding the pointer to `rect` also keeps the deref visibly guarded,
|
|
||||||
// instead of reading req.Rect.MinX under an invariant from a line above.)
|
|
||||||
if rect.degenerate() {
|
|
||||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
|
|
||||||
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM)
|
|
||||||
} else {
|
|
||||||
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 200, not 201: a fill can legitimately create nothing (the region is already
|
|
||||||
// planted), and there is no single resource to point a Location at.
|
|
||||||
c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearObject soft-removes every active plop in an object.
|
|
||||||
//
|
|
||||||
// Distinct from deleting the object, and — unlike the client-side loop this
|
|
||||||
// replaces — it lands as ONE change set, so undoing a cleared bed is one click
|
|
||||||
// rather than one per plop.
|
|
||||||
func (h *handlers) clearObject(c *gin.Context) {
|
|
||||||
id, ok := parseIDParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id)
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"cleared": n})
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func fillPath(id int64) string { return objectPath(id) + "/fill" }
|
|
||||||
func clearPath(id int64) string { return objectPath(id) + "/clear" }
|
|
||||||
|
|
||||||
// makeFillPlant creates a custom plant and returns its id. (A near-identical
|
|
||||||
// createPlantAPI landed alongside the seed-lot tests; consolidating the two into
|
|
||||||
// one shared helper is a fine follow-up, kept separate here only to avoid a
|
|
||||||
// merge collision on the shared symbol.)
|
|
||||||
func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
|
||||||
t.Helper()
|
|
||||||
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
|
||||||
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
|
||||||
}, cookie)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedFillableBed makes a garden with one plantable bed and a custom plant,
|
|
||||||
// returning (gardenID, objectID, plantID).
|
|
||||||
func seedFillableBed(t *testing.T, r *gin.Engine, cookie *http.Cookie, w, h, spacing float64) (int64, int64, int64) {
|
|
||||||
t.Helper()
|
|
||||||
gid := createGardenAPI(t, r, cookie, "G")
|
|
||||||
rec := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
|
||||||
"kind": "bed", "widthCm": w, "heightCm": h, "plantable": true,
|
|
||||||
}, cookie)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create bed: status %d, body %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
objID := int64(decodeMap(t, rec.Body.Bytes())["id"].(float64))
|
|
||||||
plantID := makeFillPlant(t, r, cookie, "Fillable", spacing)
|
|
||||||
return gid, objID, plantID
|
|
||||||
}
|
|
||||||
|
|
||||||
// countChangeSets reads the history page and reports how many change sets exist.
|
|
||||||
func countChangeSets(t *testing.T, r *gin.Engine, cookie *http.Cookie, gardenID int64) int {
|
|
||||||
t.Helper()
|
|
||||||
w := doJSON(t, r, http.MethodGet, historyPath(gardenID), nil, cookie)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
sets, _ := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
|
|
||||||
return len(sets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFillAndClearAPI covers the two routes end to end through the router.
|
|
||||||
//
|
|
||||||
// These exist because both operations were previously reachable ONLY through the
|
|
||||||
// agent toolbox, so on an instance with no model configured the most valuable
|
|
||||||
// bulk operation in the app did not exist at all.
|
|
||||||
func TestFillAndClearAPI(t *testing.T) {
|
|
||||||
r := authEngine(t, localCfg())
|
|
||||||
cookie := registerAndCookie(t, r, "[email protected]")
|
|
||||||
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
|
||||||
|
|
||||||
// Fill by compass name.
|
|
||||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
||||||
"plantId": plantID, "region": "all",
|
|
||||||
}, cookie)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
body := decodeMap(t, w.Body.Bytes())
|
|
||||||
created := int(body["created"].(float64))
|
|
||||||
if created == 0 {
|
|
||||||
t.Fatalf("fill created nothing: %s", w.Body.String())
|
|
||||||
}
|
|
||||||
if plops, _ := body["plantings"].([]any); len(plops) != created {
|
|
||||||
t.Errorf("created=%d but returned %d plantings", created, len(plops))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear it: one call, and it reports what it removed.
|
|
||||||
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("clear: status %d, body %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != created {
|
|
||||||
t.Errorf("cleared %d, want %d (everything the fill made)", n, created)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clearing an already-empty bed is a no-op, not an error.
|
|
||||||
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("second clear: status %d", w.Code)
|
|
||||||
}
|
|
||||||
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
|
|
||||||
t.Errorf("second clear removed %d, want 0", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
|
|
||||||
// its own corner of the bed.
|
|
||||||
func TestFillRegionSelectionAPI(t *testing.T) {
|
|
||||||
r := authEngine(t, localCfg())
|
|
||||||
cookie := registerAndCookie(t, r, "[email protected]")
|
|
||||||
_, objID, plantID := seedFillableBed(t, r, cookie, 400, 400, 20)
|
|
||||||
|
|
||||||
// Neither → 400. Both → 400. Accepting both and silently preferring one
|
|
||||||
// would make a client bug look like a geometry bug.
|
|
||||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{"plantId": plantID}, cookie); w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("no region = %d, want 400", w.Code)
|
|
||||||
}
|
|
||||||
both := map[string]any{
|
|
||||||
"plantId": plantID, "region": "all",
|
|
||||||
"rect": map[string]any{"minXCm": -50, "minYCm": -50, "maxXCm": 50, "maxYCm": 50},
|
|
||||||
}
|
|
||||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), both, cookie); w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("both region and rect = %d, want 400", w.Code)
|
|
||||||
}
|
|
||||||
// An unknown compass name is rejected rather than silently filling nothing.
|
|
||||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
||||||
"plantId": plantID, "region": "middle-ish",
|
|
||||||
}, cookie); w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("bad region name = %d, want 400", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A zero-area rect is malformed input, not "plant one at the centre". An empty
|
|
||||||
// `"rect": {}` decodes to all-zeros and must be caught the same way.
|
|
||||||
for _, rect := range []map[string]any{
|
|
||||||
{}, // {} → 0,0,0,0
|
|
||||||
{"minXCm": 10, "minYCm": 10, "maxXCm": 10, "maxYCm": 50}, // zero width
|
|
||||||
{"minXCm": 10, "minYCm": 50, "maxXCm": 50, "maxYCm": 50}, // zero height
|
|
||||||
{"minXCm": 50, "minYCm": 50, "maxXCm": 10, "maxYCm": 10}, // inverted
|
|
||||||
} {
|
|
||||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID),
|
|
||||||
map[string]any{"plantId": plantID, "rect": rect}, cookie); w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("degenerate rect %v = %d, want 400", rect, w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A rect confined to the NE corner produces plops only there. Local frame:
|
|
||||||
// +x east, -y north.
|
|
||||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
||||||
"plantId": plantID,
|
|
||||||
"rect": map[string]any{"minXCm": 0, "minYCm": -200, "maxXCm": 200, "maxYCm": 0},
|
|
||||||
}, cookie)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("rect fill: status %d, body %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
plops, _ := decodeMap(t, w.Body.Bytes())["plantings"].([]any)
|
|
||||||
if len(plops) == 0 {
|
|
||||||
t.Fatal("rect fill created nothing")
|
|
||||||
}
|
|
||||||
for _, raw := range plops {
|
|
||||||
p := raw.(map[string]any)
|
|
||||||
if x, y := p["xCm"].(float64), p["yCm"].(float64); x < 0 || y > 0 {
|
|
||||||
t.Errorf("plop at (%v,%v) outside the NE rect", x, y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClearObjectIsOneChangeSetAPI is the regression test for the behaviour this
|
|
||||||
// endpoint exists to restore.
|
|
||||||
//
|
|
||||||
// The UI used to clear a bed with a loop of PATCHes, and since every service
|
|
||||||
// mutation auto-scopes its own change set, clearing a 40-plop bed wrote 40 of
|
|
||||||
// them — 40 presses of Undo to put the bed back. CLAUDE.md states the rule
|
|
||||||
// directly: multi-row operations record together so they undo as one unit.
|
|
||||||
func TestClearObjectIsOneChangeSetAPI(t *testing.T) {
|
|
||||||
r := authEngine(t, localCfg())
|
|
||||||
cookie := registerAndCookie(t, r, "[email protected]")
|
|
||||||
gid, objID, plantID := seedFillableBed(t, r, cookie, 300, 300, 20)
|
|
||||||
|
|
||||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
||||||
"plantId": plantID, "region": "all",
|
|
||||||
}, cookie)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
created := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
|
||||||
if created < 4 {
|
|
||||||
t.Fatalf("need several plops to make this meaningful, got %d", created)
|
|
||||||
}
|
|
||||||
|
|
||||||
before := countChangeSets(t, r, cookie, gid)
|
|
||||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("clear: status %d", w.Code)
|
|
||||||
}
|
|
||||||
if after := countChangeSets(t, r, cookie, gid); after != before+1 {
|
|
||||||
t.Errorf("clearing %d plops added %d change sets, want exactly 1", created, after-before)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFillClearPermissionsAPI: a viewer may look but not fill or clear, and a
|
|
||||||
// stranger gets 404 because existence is masked.
|
|
||||||
func TestFillClearPermissionsAPI(t *testing.T) {
|
|
||||||
r := authEngine(t, localCfg())
|
|
||||||
owner := registerAndCookie(t, r, "[email protected]")
|
|
||||||
viewer := registerAndCookie(t, r, "[email protected]")
|
|
||||||
stranger := registerAndCookie(t, r, "[email protected]")
|
|
||||||
|
|
||||||
gid, objID, plantID := seedFillableBed(t, r, owner, 200, 200, 20)
|
|
||||||
if w := doJSON(t, r, http.MethodPost, sharesPath(gid),
|
|
||||||
map[string]any{"email": "[email protected]", "role": "viewer"}, owner); w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("share as viewer: status %d, body %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
fillBody := map[string]any{"plantId": plantID, "region": "all"}
|
|
||||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, viewer); w.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("viewer fill = %d, want 403 (they can see it but may not do that)", w.Code)
|
|
||||||
}
|
|
||||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, viewer); w.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("viewer clear = %d, want 403", w.Code)
|
|
||||||
}
|
|
||||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, stranger); w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("stranger fill = %d, want 404 (existence masked)", w.Code)
|
|
||||||
}
|
|
||||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, stranger); w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("stranger clear = %d, want 404", w.Code)
|
|
||||||
}
|
|
||||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("anonymous fill = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, nil); w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("anonymous clear = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedLotPath(id int64) string {
|
||||||
|
return "/api/v1/seed-lots/" + strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeList decodes a bare JSON array body. Seed lot listing returns the array
|
||||||
|
// directly rather than wrapping it (unlike /journal's {"entries": …}), so a
|
||||||
|
// helper that assumed an object would quietly read nothing.
|
||||||
|
func decodeList(t *testing.T, body []byte) []any {
|
||||||
|
t.Helper()
|
||||||
|
var out []any
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
t.Fatalf("decode list: %v (%s)", err, body)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPlantAPI makes a custom plant and returns its id.
|
||||||
|
func createPlantAPI(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
||||||
|
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotCrudAPI walks the whole seed lot lifecycle over HTTP.
|
||||||
|
//
|
||||||
|
// It exists for the reason CLAUDE.md gives: service tests cannot see a route
|
||||||
|
// that was never registered, or one registered with the wrong :param name. Every
|
||||||
|
// other handler file had a sibling API test; this group did not, which is the
|
||||||
|
// state PATCH/DELETE /journal/:id shipped in — implemented, unit-tested, and
|
||||||
|
// completely unreachable.
|
||||||
|
func TestSeedLotCrudAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
plantID := createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
||||||
|
|
||||||
|
// Create.
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||||
|
"plantId": plantID, "vendor": "Johnny's", "sku": "2761",
|
||||||
|
"quantity": 100, "unit": "seeds", "packedForYear": 2026, "costCents": 495,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
lot := decodeMap(t, w.Body.Bytes())
|
||||||
|
id := int64(lot["id"].(float64))
|
||||||
|
if lot["vendor"] != "Johnny's" || lot["unit"] != "seeds" {
|
||||||
|
t.Errorf("unexpected lot: %+v", lot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET by id — the route most likely to be missing or mis-registered.
|
||||||
|
w = doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := decodeMap(t, w.Body.Bytes()); int64(got["id"].(float64)) != id {
|
||||||
|
t.Errorf("get returned id %v, want %d", got["id"], id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List, and the ?plantId= filter.
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := len(decodeList(t, w.Body.Bytes())); n != 1 {
|
||||||
|
t.Fatalf("list returned %d lots, want 1: %s", n, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
other := createPlantAPI(t, r, cookie, "Cherokee Purple", 45)
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+strconv.FormatInt(other, 10), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("filtered list: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||||
|
t.Errorf("filter by a plant with no lots returned %d, want 0", n)
|
||||||
|
}
|
||||||
|
// A bad plantId filter is 400, whether non-numeric or out of range — the
|
||||||
|
// handler rejects id < 1, not just unparseable strings.
|
||||||
|
for _, bad := range []string{"nope", "0", "-1"} {
|
||||||
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+bad, nil, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("plantId=%q filter = %d, want 400", bad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH with the current version.
|
||||||
|
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||||
|
"vendor": "Fedco", "version": lot["version"],
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
updated := decodeMap(t, w.Body.Bytes())
|
||||||
|
if updated["vendor"] != "Fedco" {
|
||||||
|
t.Errorf("vendor = %v, want Fedco", updated["vendor"])
|
||||||
|
}
|
||||||
|
if updated["version"].(float64) != lot["version"].(float64)+1 {
|
||||||
|
t.Errorf("patch didn't bump version: %v -> %v", lot["version"], updated["version"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale version conflicts and carries the current row back, so the client
|
||||||
|
// can rebase without a second request.
|
||||||
|
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||||
|
"vendor": "stale", "version": lot["version"],
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("stale patch: status %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["vendor"] != "Fedco" {
|
||||||
|
t.Errorf("409 body missing the current row: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE.
|
||||||
|
if w := doJSON(t, r, http.MethodDelete, seedLotPath(id), nil, cookie); w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("get after delete = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotRemainingIsDerivedAPI checks that `remaining` reflects plantings
|
||||||
|
// through the HTTP surface, not just in the service.
|
||||||
|
//
|
||||||
|
// DESIGN.md makes derivation load-bearing — "a decremented column drifts the
|
||||||
|
// moment a planting is edited behind its back" — so a route that returned a
|
||||||
|
// stored or stale figure would break the invariant silently, and the number is
|
||||||
|
// the whole reason anyone opens the seed shelf.
|
||||||
|
func TestSeedLotRemainingIsDerivedAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
plantID := createPlantAPI(t, r, cookie, "Beans", 10)
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||||
|
"plantId": plantID, "quantity": 50, "unit": "seeds",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create lot: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
w = doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||||
|
"kind": "bed", "widthCm": 100, "heightCm": 100, "plantable": true,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
// Plant 12 of them against the lot.
|
||||||
|
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||||
|
"plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 20, "count": 12, "seedLotId": lotID,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create planting: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get lot: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
got := decodeMap(t, w.Body.Bytes())
|
||||||
|
if rem, ok := got["remaining"].(float64); !ok || rem != 38 {
|
||||||
|
t.Errorf("remaining = %v, want 38 (50 bought - 12 planted): %s", got["remaining"], w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over-plant it: 45 more (57 total against 50 bought) drives remaining
|
||||||
|
// NEGATIVE. That's deliberate — the number is a derived truth about what
|
||||||
|
// you've committed, not a floor clamped at zero, and "you've planted more than
|
||||||
|
// you bought" is exactly the signal a gardener wants rather than a hidden -7.
|
||||||
|
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||||
|
"plantId": plantID, "xCm": 40, "yCm": 40, "radiusCm": 20, "count": 45, "seedLotId": lotID,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("over-plant: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||||
|
if rem, ok := decodeMap(t, w.Body.Bytes())["remaining"].(float64); !ok || rem != -7 {
|
||||||
|
t.Errorf("remaining after over-planting = %v, want -7 (50 - 57)", rem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotsArePrivateAPI checks the ACL through the router.
|
||||||
|
//
|
||||||
|
// Lots are private to the buyer and deliberately never travel with a shared
|
||||||
|
// garden, so another user must not be able to read or edit one. Per the
|
||||||
|
// project's convention, no-access is ErrNotFound rather than ErrForbidden —
|
||||||
|
// existence is masked — so every one of these is a 404, not a 403.
|
||||||
|
func TestSeedLotsArePrivateAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
alice := registerAndCookie(t, r, "[email protected]")
|
||||||
|
bob := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
plantID := createPlantAPI(t, r, alice, "Alice's garlic", 15)
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||||
|
"plantId": plantID, "vendor": "Secret Vendor", "quantity": 10, "unit": "bulbs",
|
||||||
|
}, alice)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("alice create: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"get", http.MethodGet, nil},
|
||||||
|
{"patch", http.MethodPatch, map[string]any{"vendor": "hijacked", "version": 1}},
|
||||||
|
{"delete", http.MethodDelete, nil},
|
||||||
|
} {
|
||||||
|
if w := doJSON(t, r, tc.method, seedLotPath(lotID), tc.body, bob); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("bob %s = %d, want 404 (existence is masked)", tc.name, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob's own listing must not include it either.
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, bob)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bob list: status %d", w.Code)
|
||||||
|
}
|
||||||
|
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||||
|
t.Errorf("bob sees %d of alice's lots, want 0: %s", n, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it's still intact for alice.
|
||||||
|
if w := doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, alice); w.Code != http.StatusOK {
|
||||||
|
t.Errorf("alice lost access to her own lot: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotsRequireAuthAPI: the group is behind requireAuth, and an
|
||||||
|
// unauthenticated caller gets 401 rather than an empty list.
|
||||||
|
func TestSeedLotsRequireAuthAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/api/v1/seed-lots"},
|
||||||
|
{http.MethodPost, "/api/v1/seed-lots"},
|
||||||
|
{http.MethodGet, seedLotPath(1)},
|
||||||
|
{http.MethodPatch, seedLotPath(1)},
|
||||||
|
{http.MethodDelete, seedLotPath(1)},
|
||||||
|
} {
|
||||||
|
if w := doJSON(t, r, tc.method, tc.path, nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s %s = %d, want 401", tc.method, tc.path, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,25 +5,23 @@ import { useClearObject } from '@/lib/objects'
|
|||||||
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
||||||
* are kept with removed_at, so history survives). */
|
* are kept with removed_at, so history survives). */
|
||||||
export function ClearBedModal({
|
export function ClearBedModal({
|
||||||
objectId,
|
|
||||||
objectName,
|
objectName,
|
||||||
plopCount,
|
plops,
|
||||||
gardenId,
|
gardenId,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
objectId: number
|
|
||||||
objectName: string
|
objectName: string
|
||||||
plopCount: number
|
plops: { id: number; version: number }[]
|
||||||
gardenId: number
|
gardenId: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const clear = useClearObject(gardenId)
|
const clear = useClearObject(gardenId)
|
||||||
|
const n = plops.length
|
||||||
return (
|
return (
|
||||||
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '}
|
||||||
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
|
||||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@@ -33,8 +31,8 @@ export function ClearBedModal({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={clear.isPending || plopCount === 0}
|
disabled={clear.isPending || n === 0}
|
||||||
onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
|
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
|
||||||
>
|
>
|
||||||
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+17
-17
@@ -328,28 +328,28 @@ export function useUpdatePlanting(gardenId: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearResultSchema = z.object({ cleared: z.number() })
|
/** Clear a bed: soft-remove every active plop in an object (a loop of PATCHes;
|
||||||
|
* a bulk ClearObject endpoint arrives with the agent seam, #19). Invalidates
|
||||||
/** Clear a bed: soft-remove every active plop in an object (#82).
|
* once at the end. Pass the object's active plops (id + current version). */
|
||||||
*
|
|
||||||
* ONE request, and so ONE change set. This used to be a loop of PATCHes, which
|
|
||||||
* meant clearing a 40-plop bed wrote 40 change sets and took 40 presses of Undo
|
|
||||||
* to put back — while the agent's clear_object, for the identical user-facing
|
|
||||||
* action, undid in a single click. The rule it violated is stated in CLAUDE.md:
|
|
||||||
* multi-row operations record all their changes together so they undo as one
|
|
||||||
* unit. Doing it server-side also removes the partial-failure case the old loop
|
|
||||||
* had to reconcile. */
|
|
||||||
export function useClearObject(gardenId: number) {
|
export function useClearObject(gardenId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (objectId: number): Promise<number> => {
|
mutationFn: async (plops: { id: number; version: number }[]) => {
|
||||||
// No body — clear takes none; passing undefined sends none rather than an
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
// empty {}. The response is just a count; validate it rather than cast.
|
// allSettled, not all: a partial failure still soft-removed some rows
|
||||||
const res = clearResultSchema.parse(await api.post(`/objects/${objectId}/clear`))
|
// server-side, so we must reconcile the cache rather than roll everything
|
||||||
return res.cleared
|
// back. Report how many failed.
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })),
|
||||||
|
)
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected').length
|
||||||
|
if (failed > 0) {
|
||||||
|
throw new Error(`${failed} of ${plops.length} plants couldn't be cleared — refresh and try again.`)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
// Reconcile on success OR partial failure, so the cache matches the server.
|
||||||
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
||||||
onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')),
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Could not clear the bed.'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -526,9 +526,8 @@ export function GardenEditorPage() {
|
|||||||
|
|
||||||
{clearing && focusedObject && (
|
{clearing && focusedObject && (
|
||||||
<ClearBedModal
|
<ClearBedModal
|
||||||
objectId={focusedObject.id}
|
|
||||||
objectName={objectDisplayName(focusedObject)}
|
objectName={objectDisplayName(focusedObject)}
|
||||||
plopCount={focusedPlops.length}
|
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))}
|
||||||
gardenId={gid}
|
gardenId={gid}
|
||||||
onClose={() => setClearing(false)}
|
onClose={() => setClearing(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user