feat(18.1-01): task 3 flat reorder on the server

- config_reorder.yaml without parentColumn on a record with no parent_id
  column compiles as a flat (WinterCMS Sortable) reorder; maxDepth above 0
  on it fails boot, an explicit parentColumn still means a tree
- ListReorder.Flat and AdminReorderTree.Flat (flat, omitempty); flat nodes
  carry parent_id null, a save writes only the sort column, a parent is a
  422 on nodes (reorder.flat_parent); 409 and duplicate rules unchanged
- swag descriptions, OpenAPI and schema.d.ts regenerated
- acme.catalog badges reorder fixture, TestReorderFlatCompile and
  TestReorderFlatSmoke; docs Flat reorder section and cabana README
This commit is contained in:
Jakub Zych
2026-10-14 15:56:57 +02:00
parent cf63fb3152
commit 9edff8890f
13 changed files with 300 additions and 28 deletions

View File

@@ -23,6 +23,10 @@ import (
// records changed since the tree was loaded (backend::lang).
const msgReorderStale = "backend::lang.reorder.stale"
// msgReorderFlatParent is the phrase key of the 422 a flat reorder answers
// for a node with a parent (backend::lang).
const msgReorderFlatParent = "backend::lang.reorder.flat_parent"
// maxReorderPartitions caps the partition choices a reorder page offers.
const maxReorderPartitions = 500
@@ -46,10 +50,12 @@ type reorderPartitionY struct {
Label string `yaml:"label"`
}
// compiledReorder is a controller's compiled config_reorder.yaml.
// compiledReorder is a controller's compiled config_reorder.yaml. A flat
// reorder (WinterCMS Sortable) has no parent column: parentColumn is empty.
type compiledReorder struct {
title string
nameFrom string
flat bool
parentColumn string
sortColumn string
maxDepth int
@@ -65,11 +71,15 @@ type compiledPartition struct {
}
// ListReorder is the list schema's `reorder` object: the controller has a
// tree reorder page (config_reorder.yaml). Partition is set when the tree is
// reorder page (config_reorder.yaml). Partition is set when the records are
// reordered within one value of a partition column, such as one menu.
type ListReorder struct {
Title string `json:"title"`
Partition *ListReorderPartition `json:"partition,omitempty"`
// Flat is true for a flat reorder (WinterCMS Sortable): the record has
// no parent column, so the records are only put in order, never nested.
// Omitted for a tree reorder.
Flat bool `json:"flat,omitempty"`
}
// ListReorderPartition names the partition column and its label.
@@ -83,6 +93,9 @@ type ListReorderPartition struct {
// partition relation that is not a belongsTo field relation of the form over
// the partition column, and a negative maxDepth fail boot. It also enforces
// that toolbar.buttons lists reorder only on a controller that has the file.
// Without a parentColumn key on a record that has no parent_id column the
// reorder is flat, as WinterCMS infers a Sortable model: only the sort
// column is written, and a maxDepth above 0 fails boot.
func compileReorder(pluginID string, cc *CompiledController, fsys fs.FS) error {
ctl := cc.Controller
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
@@ -125,7 +138,11 @@ func compileReorder(pluginID string, cc *CompiledController, fsys fs.FS) error {
maxDepth: doc.MaxDepth,
}
if out.parentColumn == "" {
out.parentColumn = "parent_id"
if _, ok := cols["parent_id"]; ok {
out.parentColumn = "parent_id"
} else {
out.flat = true
}
}
if out.sortColumn == "" {
out.sortColumn = "sort_order"
@@ -133,10 +150,17 @@ func compileReorder(pluginID string, cc *CompiledController, fsys fs.FS) error {
if doc.MaxDepth < 0 {
return fail(fmt.Errorf("maxDepth must be 0 (unlimited) or more"))
}
if out.flat && doc.MaxDepth > 0 {
return fail(fmt.Errorf("maxDepth needs a tree reorder (the record has no parent_id column)"))
}
if !identifier(out.nameFrom) {
return fail(fmt.Errorf("nameFrom %q is not a column name", out.nameFrom))
}
for key, column := range map[string]string{"nameFrom": out.nameFrom, "parentColumn": out.parentColumn, "sortColumn": out.sortColumn} {
checked := map[string]string{"nameFrom": out.nameFrom, "sortColumn": out.sortColumn}
if !out.flat {
checked["parentColumn"] = out.parentColumn
}
for key, column := range checked {
if _, ok := cols[column]; !ok || !identifier(column) {
return fail(fmt.Errorf("%s %s is not a column of the record", key, column))
}
@@ -162,7 +186,7 @@ func compileReorder(pluginID string, cc *CompiledController, fsys fs.FS) error {
out.partition = &compiledPartition{column: p.Column, relation: p.Relation, label: p.Label, labelCol: labelCol, fieldRel: fr}
}
cc.reorder = out
reorder := &ListReorder{Title: out.title}
reorder := &ListReorder{Title: out.title, Flat: out.flat}
if out.partition != nil {
reorder.Partition = &ListReorderPartition{Column: out.partition.column, Label: out.partition.label}
}
@@ -200,6 +224,10 @@ type AdminReorderTree struct {
Title string `json:"title"`
Partition *AdminReorderPartition `json:"partition"`
Nodes []AdminReorderNode `json:"nodes"`
// Flat is true for a flat reorder (WinterCMS Sortable): every node's
// parent_id is null and a save writes only the sort column. Omitted for
// a tree reorder.
Flat bool `json:"flat,omitempty"`
}
// AdminReorderNodeInput is one node of a reorder save: the record id, its new
@@ -293,7 +321,8 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
if err != nil {
return err
}
if err := checkReorderNodes(cc.reorder, locked.Nodes, in.Nodes); err != nil {
flatParent := translateKey(ctx, s.translator(), msgReorderFlatParent)
if err := checkReorderNodes(cc.reorder, locked.Nodes, in.Nodes, flatParent); err != nil {
return err
}
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
@@ -303,12 +332,17 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
if node.ParentID != nil {
parent = castPK(proto, uint(*node.ParentID))
}
// UpdateColumns writes exactly the two columns and runs no
// model hooks, so a save hook that renumbers sort values
// cannot undo the new order.
// UpdateColumns writes exactly the two columns (the sort
// column alone on a flat reorder) and runs no model hooks, so
// a save hook that renumbers sort values cannot undo the new
// order.
columns := map[string]any{cc.reorder.sortColumn: node.SortOrder}
if !cc.reorder.flat {
columns[cc.reorder.parentColumn] = parent
}
err := tx.WithContext(txCtx).Model(cc.Controller.(pact.AdminRecordSource).NewRecord()).
Where(clause.Eq{Column: clause.Column{Name: pk}, Value: castPK(proto, uint(node.ID))}).
UpdateColumns(map[string]any{cc.reorder.parentColumn: parent, cc.reorder.sortColumn: node.SortOrder}).Error
UpdateColumns(columns).Error
if err != nil {
return lifecycleFailure(cc, err)
}
@@ -347,7 +381,7 @@ func (s *service) writeReorderError(w http.ResponseWriter, r *http.Request, cc *
func (s *service) reorderTree(ctx context.Context, tx *gorm.DB, cc *CompiledController, requested *uint64, lock bool) (AdminReorderTree, error) {
ro := cc.reorder
tr := s.translator()
tree := AdminReorderTree{Title: translateKey(ctx, tr, ro.title), Nodes: []AdminReorderNode{}}
tree := AdminReorderTree{Title: translateKey(ctx, tr, ro.title), Nodes: []AdminReorderNode{}, Flat: ro.flat}
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
q := tx.WithContext(ctx).Model(proto)
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
@@ -397,8 +431,10 @@ func (s *service) reorderTree(ctx context.Context, tx *gorm.DB, cc *CompiledCont
for i := 0; i < rows.Len(); i++ {
row := rows.Index(i)
node := AdminReorderNode{ID: uintOf(fieldByColumn(row, pk))}
if parent := uintOf(fieldByColumn(row, ro.parentColumn)); parent != 0 {
node.ParentID = &parent
if !ro.flat {
if parent := uintOf(fieldByColumn(row, ro.parentColumn)); parent != 0 {
node.ParentID = &parent
}
}
node.SortOrder = intOf(fieldByColumn(row, ro.sortColumn))
if f := fieldByColumn(row, ro.nameFrom); f.IsValid() && f.CanInterface() {
@@ -434,8 +470,10 @@ func reorderPartitions(ctx context.Context, tx *gorm.DB, cc *CompiledController,
// checkReorderNodes compares a submitted tree with the locked set: a
// duplicate id is a 422, any difference between the two id sets a 409, and a
// parent outside the set, a cycle or a depth above maxDepth a 422.
func checkReorderNodes(ro *compiledReorder, locked []AdminReorderNode, nodes []AdminReorderNodeInput) error {
// parent outside the set, a cycle or a depth above maxDepth a 422. On a flat
// reorder any parent is a 422 with flatParent and the tree checks are
// skipped.
func checkReorderNodes(ro *compiledReorder, locked []AdminReorderNode, nodes []AdminReorderNodeInput, flatParent string) error {
invalid := func(msg string) error {
return &ValidationError{Details: map[string]any{"nodes": []string{msg}}}
}
@@ -447,6 +485,9 @@ func checkReorderNodes(ro *compiledReorder, locked []AdminReorderNode, nodes []A
if _, dup := parents[node.ID]; dup {
return invalid("The nodes field lists a record twice.")
}
if ro.flat && node.ParentID != nil {
return invalid(flatParent)
}
parent := uint64(0)
if node.ParentID != nil {
parent = *node.ParentID