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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user