feat: node section assignment for sidebar filtering + search fix
Backend: - Migration 004: add 'section' column to nodes table (NULL=inbox, values: clients/projects/recipes/documents/archive) - Create(parentID, type, title, section) — section stored on root nodes - ListRoots(includeDeleted, section) — filters by section (section='inbox' returns nodes with NULL section) - GET /api/nodes?section=X filters root nodes by section - POST /api/nodes accepts 'section' field in body Frontend: - Sidebar separates 'НАВИГАЦИЯ' (virtual sections) from 'ДЕЛА' (real nodes) - Each section loads only its own nodes: GET /api/nodes?section=clients etc. - Creating from a section sets the section automatically - Inbox shows only nodes with no section - selectBySearch(id) closes result dropdown after selection - All types shown in Russian (Дело, Заметка, Папка, etc.) Acceptance: go build pass, go test pass (all packages), manual: Pro projects section shows only project-nodes, clients only client-nodes, inbox only unsectioned nodes.
This commit is contained in:
@@ -13,6 +13,7 @@ type Node struct {
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
Section string `json:"section,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -52,10 +52,15 @@ func now() string {
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
// Valid sections for root-level nodes.
|
||||
var validSections = map[string]struct{}{
|
||||
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
|
||||
}
|
||||
|
||||
// Create inserts a root or child node.
|
||||
// parentID may be empty for root-level nodes.
|
||||
// The id, timestamps, revision and slug are generated if not provided.
|
||||
func (r *Repository) Create(parentID string, typ, title string) (*Node, error) {
|
||||
// For root nodes, section determines sidebar placement (may be empty → inbox).
|
||||
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
|
||||
if !IsValidType(typ) {
|
||||
return nil, fmt.Errorf("invalid node type: %s", typ)
|
||||
}
|
||||
@@ -68,6 +73,7 @@ func (r *Repository) Create(parentID string, typ, title string) (*Node, error) {
|
||||
Type: typ,
|
||||
Title: title,
|
||||
Slug: Slugify(title),
|
||||
Section: section,
|
||||
SortOrder: 0,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
@@ -89,12 +95,16 @@ func (r *Repository) insertNode(n *Node) error {
|
||||
if n.ParentID != nil {
|
||||
parent = *n.ParentID
|
||||
}
|
||||
var sec interface{}
|
||||
if n.Section != "" {
|
||||
sec = n.Section
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO nodes (id,parent_id,type,title,slug,path,sort_order,
|
||||
`INSERT INTO nodes (id,parent_id,type,title,slug,path,section,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
n.ID, parent, n.Type, n.Title, n.Slug, n.Path,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
n.ID, parent, n.Type, n.Title, n.Slug, n.Path, sec,
|
||||
n.SortOrder, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
|
||||
n.DeletedAt, n.Revision, n.DeviceID,
|
||||
)
|
||||
@@ -104,7 +114,7 @@ func (r *Repository) insertNode(n *Node) error {
|
||||
// Get returns a plain node (even if soft-deleted).
|
||||
func (r *Repository) Get(id string) (*Node, error) {
|
||||
row := r.db.QueryRow(
|
||||
`SELECT id,parent_id,type,title,slug,path,sort_order,
|
||||
`SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes WHERE id = ?`, id)
|
||||
return scanNode(row)
|
||||
@@ -125,7 +135,7 @@ func (r *Repository) GetActive(id string) (*Node, error) {
|
||||
// ListChildren returns direct children ordered by sort_order, then title.
|
||||
// IncludeDeleted lists soft-deleted children too.
|
||||
func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node, error) {
|
||||
q := `SELECT id,parent_id,type,title,slug,path,sort_order,
|
||||
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes WHERE parent_id = ?`
|
||||
if !includeDeleted {
|
||||
@@ -142,16 +152,29 @@ func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node,
|
||||
}
|
||||
|
||||
// ListRoots returns nodes with no parent (top-level).
|
||||
func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
|
||||
q := `SELECT id,parent_id,type,title,slug,path,sort_order,
|
||||
// When section is set, only returns roots with that exact section
|
||||
// (or section IS NULL when section="inbox").
|
||||
func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, error) {
|
||||
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes WHERE parent_id IS NULL`
|
||||
if section == "inbox" {
|
||||
q += " AND section IS NULL"
|
||||
} else if section != "" {
|
||||
q += " AND section = ?"
|
||||
}
|
||||
if !includeDeleted {
|
||||
q += " AND deleted_at IS NULL"
|
||||
}
|
||||
q += " ORDER BY sort_order, title"
|
||||
|
||||
rows, err := r.db.Query(q)
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if section != "" && section != "inbox" {
|
||||
rows, err = r.db.Query(q, section)
|
||||
} else {
|
||||
rows, err = r.db.Query(q)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -270,11 +293,11 @@ type scanner interface {
|
||||
|
||||
func scanNode(s scanner) (*Node, error) {
|
||||
var n Node
|
||||
var parentID, path, deletedAt, deviceID sql.NullString
|
||||
var parentID, path, section, deletedAt, deviceID sql.NullString
|
||||
var createdStr, updatedStr string
|
||||
|
||||
err := s.Scan(
|
||||
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path,
|
||||
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path, §ion,
|
||||
&n.SortOrder, &createdStr, &updatedStr, &deletedAt,
|
||||
&n.Revision, &deviceID,
|
||||
)
|
||||
@@ -291,6 +314,9 @@ func scanNode(s scanner) (*Node, error) {
|
||||
if path.Valid {
|
||||
n.Path = &path.String
|
||||
}
|
||||
if section.Valid {
|
||||
n.Section = section.String
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, deletedAt.String)
|
||||
n.DeletedAt = &t
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestCreateAndGet(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, err := repo.Create("", TypeCase, "Test Case")
|
||||
n, err := repo.Create("", TypeCase, "Test Case", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
@@ -69,12 +69,12 @@ func TestCreateChild(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
parent, err := repo.Create("", TypeFolder, "Folder")
|
||||
parent, err := repo.Create("", TypeFolder, "Folder", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
child, err := repo.Create(parent.ID, TypeCase, "Child")
|
||||
child, err := repo.Create(parent.ID, TypeCase, "Child", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -87,9 +87,9 @@ func TestListChildren(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
parent, _ := repo.Create("", TypeFolder, "Folder")
|
||||
repo.Create(parent.ID, TypeCase, "A")
|
||||
repo.Create(parent.ID, TypeCase, "B")
|
||||
parent, _ := repo.Create("", TypeFolder, "Folder", "")
|
||||
repo.Create(parent.ID, TypeCase, "A", "")
|
||||
repo.Create(parent.ID, TypeCase, "B", "")
|
||||
|
||||
children, err := repo.ListChildren(parent.ID, false)
|
||||
if err != nil {
|
||||
@@ -108,10 +108,10 @@ func TestListRoots(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
repo.Create("", TypeCase, "One")
|
||||
repo.Create("", TypeCase, "Two")
|
||||
repo.Create("", TypeCase, "One", "")
|
||||
repo.Create("", TypeCase, "Two", "")
|
||||
|
||||
roots, err := repo.ListRoots(false)
|
||||
roots, err := repo.ListRoots(false, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func TestUpdateTitle(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, _ := repo.Create("", TypeCase, "Old")
|
||||
n, _ := repo.Create("", TypeCase, "Old", "")
|
||||
if err := repo.UpdateTitle(n.ID, "New Title"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -142,9 +142,9 @@ func TestMove(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
a, _ := repo.Create("", TypeFolder, "A")
|
||||
b, _ := repo.Create("", TypeFolder, "B")
|
||||
child, _ := repo.Create(a.ID, TypeCase, "Child")
|
||||
a, _ := repo.Create("", TypeFolder, "A", "")
|
||||
b, _ := repo.Create("", TypeFolder, "B", "")
|
||||
child, _ := repo.Create(a.ID, TypeCase, "Child", "")
|
||||
|
||||
// Move child from A to B.
|
||||
if err := repo.Move(child.ID, b.ID, 0); err != nil {
|
||||
@@ -170,7 +170,7 @@ func TestSoftDelete(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, _ := repo.Create("", TypeCase, "To Delete")
|
||||
n, _ := repo.Create("", TypeCase, "To Delete", "")
|
||||
if err := repo.SoftDelete(n.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -189,8 +189,8 @@ func TestSoftDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
// ListChildren without includeDeleted must skip it.
|
||||
parent, _ := repo.Create("", TypeFolder, "P")
|
||||
child, _ := repo.Create(parent.ID, TypeCase, "Kid")
|
||||
parent, _ := repo.Create("", TypeFolder, "P", "")
|
||||
child, _ := repo.Create(parent.ID, TypeCase, "Kid", "")
|
||||
repo.SoftDelete(child.ID)
|
||||
|
||||
kids, _ := repo.ListChildren(parent.ID, false)
|
||||
@@ -208,7 +208,7 @@ func TestMetaKV(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, _ := repo.Create("", TypeCase, "M")
|
||||
n, _ := repo.Create("", TypeCase, "M", "")
|
||||
if err := repo.MetaSet(n.ID, "status", "active"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -266,7 +266,7 @@ func TestInitEndToEnd(t *testing.T) {
|
||||
defer db.Close()
|
||||
|
||||
repo := NewRepository(db)
|
||||
n, err := repo.Create("", TypeCase, "Integration Case")
|
||||
n, err := repo.Create("", TypeCase, "Integration Case", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user