Files
vibetree/internal/web/handlers_folders.go
T
Owen 4ceb1cbc03 Replace on-screen action buttons with right-click context menus
The tree and main panels now behave like a native file manager: right
click (or the keyboard Menu key) on a folder, asset, breadcrumb crumb, or
empty tree/panel background brings up the relevant actions instead of a
row of always-visible buttons.

- New generic showContextMenu() in app.js with per-target-kind menu
  builders (folder/asset/tree background/panel background), gated by
  admin role the same way the old buttons were.
- Removed: the "..." tree-row action button, and the New Folder/Add
  Asset/Rename/Delete/Edit/Move/Duplicate/Import/Export buttons from the
  inventory toolbar and every panel. Expand All/Collapse All shrank to two
  icon-only buttons next to the search box; everything else creation- or
  mutation-related is right-click only.
- Replaced the per-asset embedded "Move" modal with one shared
  #moveAssetModal populated via a new GET /app/folders/options endpoint,
  since move can now be triggered from any asset row, not just its own
  detail view.
- Breadcrumb crumbs (including the current folder) and folder/asset table
  rows are now valid right-click targets too, not just tree rows.
2026-08-17 15:24:32 -05:00

226 lines
5.4 KiB
Go

package web
import (
"errors"
"net/http"
"inventree/internal/folders"
"inventree/internal/models"
)
// handleFolderOptions returns every folder as a flat, indented list for
// generic "move to..." pickers (e.g. the asset move context-menu action).
func (a *App) handleFolderOptions(w http.ResponseWriter, r *http.Request) {
opts, err := a.folderOptions(r.Context())
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
jsonOK(w, opts)
}
func (a *App) handleFolderGet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
f, err := a.Folders.Get(r.Context(), id)
if err != nil {
jsonErr(w, http.StatusNotFound, err)
return
}
jsonOK(w, f)
}
type folderCreateRequest struct {
Name string `json:"name"`
ParentID *int64 `json:"parent_id"`
Description string `json:"description"`
Icon string `json:"icon"`
SortOrder int `json:"sort_order"`
}
func (a *App) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
var req folderCreateRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
f := &models.Folder{Name: req.Name, ParentID: req.ParentID, Description: req.Description, Icon: req.Icon, SortOrder: req.SortOrder}
if err := a.Folders.Create(r.Context(), f); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
jsonOK(w, f)
}
type folderUpdateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
SortOrder int `json:"sort_order"`
}
func (a *App) handleFolderUpdate(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req folderUpdateRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
f, err := a.Folders.Get(r.Context(), id)
if err != nil {
jsonErr(w, http.StatusNotFound, err)
return
}
f.Name, f.Description, f.Icon, f.SortOrder = req.Name, req.Description, req.Icon, req.SortOrder
if err := a.Folders.Update(r.Context(), f); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
jsonOK(w, f)
}
type folderMoveRequest struct {
ParentID *int64 `json:"parent_id"`
}
func (a *App) handleFolderMove(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
var req folderMoveRequest
if err := readJSON(r, &req); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
if err := a.Folders.Move(r.Context(), id, req.ParentID); err != nil {
status := http.StatusBadRequest
if errors.Is(err, folders.ErrInvalidParent) {
status = http.StatusConflict
}
jsonErr(w, status, err)
return
}
jsonOK(w, map[string]any{"id": id, "parent_id": req.ParentID})
}
func (a *App) handleFolderDeleteInfo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
folderCount, assetCount, err := a.Folders.Counts(ctx, id)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
f, err := a.Folders.Get(ctx, id)
if err != nil {
jsonErr(w, http.StatusNotFound, err)
return
}
all, err := a.Folders.All(ctx)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err)
return
}
descendants := map[int64]bool{id: true}
changed := true
for changed {
changed = false
for _, cand := range all {
if cand.ParentID != nil && descendants[*cand.ParentID] && !descendants[cand.ID] {
descendants[cand.ID] = true
changed = true
}
}
}
byID := map[int64]models.Folder{}
for _, cand := range all {
byID[cand.ID] = cand
}
var pathOf func(int64) string
pathOf = func(fid int64) string {
ff, ok := byID[fid]
if !ok {
return ""
}
if ff.ParentID == nil {
return ff.Name
}
return pathOf(*ff.ParentID) + " / " + ff.Name
}
type dest struct {
ID int64 `json:"id"`
Path string `json:"path"`
}
destinations := []dest{{ID: 0, Path: "Inventory (top level)"}}
for _, cand := range all {
if descendants[cand.ID] {
continue
}
destinations = append(destinations, dest{ID: cand.ID, Path: pathOf(cand.ID)})
}
jsonOK(w, map[string]any{
"folder_name": f.Name,
"folder_count": folderCount,
"asset_count": assetCount,
"destinations": destinations,
})
}
func (a *App) handleFolderDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r, "id")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
mode := r.URL.Query().Get("mode")
switch mode {
case "relocate":
var dest *int64
if raw := r.URL.Query().Get("dest"); raw != "" && raw != "0" {
d, err := queryOptionalID(r, "dest")
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
dest = d
}
if err := a.Folders.DeleteAndRelocate(ctx, id, dest); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
case "cascade":
if err := a.Folders.CascadeDelete(ctx, id); err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
}
default:
if err := a.Folders.Delete(ctx, id); err != nil {
status := http.StatusBadRequest
if errors.Is(err, folders.ErrNotEmpty) {
status = http.StatusConflict
}
jsonErr(w, status, err)
return
}
}
jsonOK(w, map[string]any{"id": id})
}