feat(17-01): tree reorder page and singleton admin controllers

- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
  sortColumn, maxDepth, partition) with fail-loud boot checks and the
  built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
  partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
  422 for duplicates, foreign parents, cycles and maxDepth, both columns
  written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
  scope checked), form schema singleton, list/create/bulk/delete 404,
  no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
  lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
This commit is contained in:
2026-10-12 15:35:43 +02:00
parent 46835fd064
commit 23f33c3911
31 changed files with 1905 additions and 15 deletions

View File

@@ -290,6 +290,31 @@ func conformCases() []conformCase {
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", 200, "cabana.Envelope-cabana_AdminDependentOptionsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, true)
}, into[cabana.Envelope[cabana.AdminDependentOptionsResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/reorder", 200, "cabana.Envelope-cabana_AdminReorderTree", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
group := companionsGroup{Title: "Conform " + e.stamp}
if err := e.db.Create(&group).Error; err != nil {
t.Fatal(err)
}
link := companionsLink{Title: "link-" + e.stamp, GroupID: &group.ID, SortOrder: 1}
if err := e.db.Create(&link).Error; err != nil {
t.Fatal(err)
}
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/companions/links/reorder?partition=%d", group.ID), nil, true)
}, into[cabana.Envelope[cabana.AdminReorderTree]](), nil},
{"POST /{vendor}/{plugin}/{controller}/reorder", 200, "cabana.Envelope-cabana_AdminReorderTree", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
tree := reorderTree(t, e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, true))
if tree.Partition == nil || tree.Partition.Value == nil || len(tree.Nodes) == 0 {
t.Fatalf("reorder tree = %+v", tree)
}
nodes := make([]map[string]any, 0, len(tree.Nodes))
for i, node := range tree.Nodes {
nodes = append(nodes, reorderNode(node.ID, nil, int64(i+1)))
}
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": *tree.Partition.Value, "nodes": nodes}, true)
}, into[cabana.Envelope[cabana.AdminReorderTree]](), nil},
{"GET /{vendor}/{plugin}/{controller}/singleton", 200, "cabana.Envelope-cabana_AdminSingletonResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
}, into[cabana.Envelope[cabana.AdminSingletonResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
}, into[cabana.Envelope[[]cabana.FilterOption]](), nil},