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

@@ -41,6 +41,7 @@ Use cases:
- Rich text: `type: richeditor` edits HTML in a host text column (the admin SPA shows a WYSIWYG editor). It takes the common field keys plus `size`; any other key fails boot, and settings and relation forms refuse the type (supported in controller forms only). On create and update a submitted value must be a string or null (anything else is a 422 on the field) and is sanitized through [seaglass](../seaglass/README.md) before it is filled: HTML the policy keeps whole is stored byte for byte, otherwise the clean HTML is stored and what was removed is reported in `meta.notices` under the field (`backend::lang.richeditor.removed_elements`, `removed_attributes`, `removed_comments` with the count and names, or `markup_changed`).
- Repeater: `type: repeater` edits a JSON list in one column (`lagoon.Jsonable`) with a nested `form.fields` of `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or `dependsOn` a field of the same row) and `richeditor` fields, plus `prompt`, `minItems`, `maxItems` and `titleFrom` (`cabana.RepeaterForm` in the schema; the span is always full). Other nested types, a nested repeater, nested `tab`, `context` or `preset`, and WinterCMS's `groups` and `style` fail boot; settings and relation forms refuse the type. A save decodes the rows before the projection: null is an empty list, anything but a list of objects is a 422 on the field, each row keeps only the nested names with scalars coerced per type, a required nested value missing in a row is a 422 under `field.index.nested`, the row bounds are 422s on the field (`repeater.max_items`, `repeater.min_items`), nested richeditor values are sanitized with notices under the row path, and nested dependent dropdowns are rechecked. Record responses carry the list of rows.
- Tree reorder (D-09): a `config_reorder.yaml` (`title`, `nameFrom`, `modelClass`, `parentColumn` default `parent_id`, `sortColumn` default `sort_order`, `maxDepth`, and an optional `partition` with `column`, `relation`, `nameFrom` and `label`) gives the controller a reorder page and the built-in `reorder` toolbar button; unknown keys, columns that are not record columns, a partition relation that is not a belongsTo relation field over the column, and a `reorder` button without the file fail boot. The list schema carries `reorder` (`cabana.ListReorder`). GET `.../reorder` answers the scoped tree (`cabana.AdminReorderTree`); POST `.../reorder` locks the scoped set (ListExtendQuery plus the partition) in one transaction, answers 409 `reorder_stale` when the posted ids differ from it in any way, 422 for a duplicate, a foreign parent, a cycle or a depth above `maxDepth`, and otherwise writes both tree columns without model hooks.
- Flat reorder (D-17): a `config_reorder.yaml` without `parentColumn` on a record that has no `parent_id` column (WinterCMS Sortable) compiles as a flat reorder: the list schema's `reorder` and the reorder routes' data carry `flat: true` (omitted on a tree, so tree payloads are unchanged), every node's `parent_id` is null, a save writes only the sort column, a node with a parent is a 422 on `nodes` (`backend::lang.reorder.flat_parent`), and the 409 stale-set and duplicate-id rules are unchanged. `maxDepth` above 0 on a flat reorder fails boot; an explicit `parentColumn` still means a tree.
- Singleton controllers (D-11): a controller implementing `pact.AdminSingleton` needs only `config_form.yaml`; GET `.../singleton` answers the id of the record `SingletonRecord` returns (created when absent; a first request that loses the creation race on a unique index runs once more and finds the record) after checking the form scope, the form schema carries `singleton`, and the list, create, bulk and delete routes answer 404. A settings link entry may open it.
- Dependent dropdowns: `dependsOn: <field>` on a `type: dropdown` field names a sibling with a scalar value (the same form, or the same repeater row); the dropdown takes no `options` and its controller implements `pact.DependentOptionsProvider`, else boot fails. GET `.../fields/{field}/dependent-options?values[<sibling>]=<value>` answers `cabana.AdminDependentOptionsResult` (`applies` and the localized `options` with their `depth`); `{field}` is `repeater.field` for a dropdown in a repeater row; the form schema carries `dependsOn`. Every save rechecks the value with the submitted sibling value (per row in a repeater): a value that does not apply is stored null, one outside the options is a 422 `form.dependent_invalid` on the field. A menu or sitemap controller returns `signpost.ReferenceOptions` here, the one admin binding of the [signpost](../signpost/README.md) menu item type registry. Settings and relation forms refuse `dependsOn` (supported in controller forms only).
- Preset fields: `preset` on a `type: text` or `type: mltext` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
@@ -247,8 +248,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.AdminBulkAction` | Swag annotation of the bulk action route. |
| `cabana.AdminDependentOptionsResult` / `cabana.AdminDependentOption` | Answer of the dependent-options route: `applies` and the options, each with `value`, localized `label` and `depth`. |
| `cabana.AdminDependentOptions` | Swag annotation of the dependent-options route. |
| `cabana.ListReorder` / `cabana.ListReorderPartition` | The list schema's `reorder` object: the page title and the partition column and label. |
| `cabana.AdminReorderTree` / `cabana.AdminReorderNode` / `cabana.AdminReorderPartition` / `cabana.AdminReorderPartitionOption` | Answer of the reorder routes: title, partition (label, options, value) and the nodes with `id`, `parent_id`, `sort_order` and `name`. |
| `cabana.ListReorder` / `cabana.ListReorderPartition` | The list schema's `reorder` object: the page title, the partition column and label, and `Flat` (`flat`, omitted when false) for a flat reorder. |
| `cabana.AdminReorderTree` / `cabana.AdminReorderNode` / `cabana.AdminReorderPartition` / `cabana.AdminReorderPartitionOption` | Answer of the reorder routes: title, partition (label, options, value), the nodes with `id`, `parent_id`, `sort_order` and `name`, and `flat` (omitted when false) on a flat reorder. |
| `cabana.AdminReorderRequest` / `cabana.AdminReorderNodeInput` | Body of the reorder save. |
| `cabana.AdminReorderGet` / `cabana.AdminReorderSave` | Swag annotations of the reorder routes. |
| `cabana.AdminSingletonResult` / `cabana.AdminSingletonRecord` | Answer and swag annotation of the singleton route. |

View File

@@ -483,10 +483,10 @@ func AdminRelationSchema() {}
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get]
func AdminFieldOptions() {}
// AdminReorderGet documents the tree reorder load route (D-09).
// AdminReorderGet documents the reorder load route (D-09, flat D-17).
//
// @Summary Load a reorder tree
// @Description The records of a controller with config_reorder.yaml, scoped by the list scope (ListExtendQuery) and, when the page has a partition, by one partition: `partition` picks it (an id among `partition.options`; another id answers 404) and defaults to the first option. Nodes carry the id, parent_id (null at the top), sort_order and the nameFrom column as name, ordered by the sort column and then the id. A controller without a reorder page answers 404.
// @Description The records of a controller with config_reorder.yaml, scoped by the list scope (ListExtendQuery) and, when the page has a partition, by one partition: `partition` picks it (an id among `partition.options`; another id answers 404) and defaults to the first option. Nodes carry the id, parent_id (null at the top), sort_order and the nameFrom column as name, ordered by the sort column and then the id. A flat reorder (no parentColumn key and no parent_id column on the record, WinterCMS Sortable) answers `flat: true` and parent_id null on every node; a tree reorder omits `flat`. A controller without a reorder page answers 404.
// @Tags admin
// @Produce json
// @Security BackendBearer
@@ -502,10 +502,10 @@ func AdminFieldOptions() {}
// @Router /{vendor}/{plugin}/{controller}/reorder [get]
func AdminReorderGet() {}
// AdminReorderSave documents the tree reorder save route (D-09).
// AdminReorderSave documents the reorder save route (D-09, flat D-17).
//
// @Summary Save a reorder tree
// @Description Saves the parent and sort columns of every record of the scoped set in one transaction that locks the set (ListExtendQuery plus the partition). `nodes` must list exactly the records of that set: any difference (a record added, removed or moved to another partition meanwhile) answers 409 `reorder_stale` and writes nothing. A duplicate id, a parent outside the set, a cycle and a depth above maxDepth answer 422 `validation_failed` on `nodes`; a missing partition on a partitioned page is a 422 on `partition`. The answer is the new tree.
// @Description Saves the parent and sort columns of every record of the scoped set in one transaction that locks the set (ListExtendQuery plus the partition). `nodes` must list exactly the records of that set: any difference (a record added, removed or moved to another partition meanwhile) answers 409 `reorder_stale` and writes nothing. A duplicate id, a parent outside the set, a cycle and a depth above maxDepth answer 422 `validation_failed` on `nodes`; a missing partition on a partitioned page is a 422 on `partition`. On a flat reorder only the sort column is written, and a node with a non-null parent_id is a 422 on `nodes` (`backend::lang.reorder.flat_parent`). The answer is the new tree.
// @Tags admin
// @Accept json
// @Produce json

View File

@@ -285,3 +285,90 @@ func TestTaglistFieldSmoke(t *testing.T) {
// customTags false: only the options are accepted.
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": []any{"alpha", "delta"}}, "tags", "Tag delta jest niedozwolony.")
}
// catalogReorder is the data of the badges reorder routes.
type catalogReorder struct {
Data struct {
Flat bool `json:"flat"`
Nodes []struct {
ID uint `json:"id"`
ParentID *uint `json:"parent_id"`
SortOrder int64 `json:"sort_order"`
Name string `json:"name"`
} `json:"nodes"`
} `json:"data"`
}
// TestReorderFlatSmoke checks the flat reorder routes on Postgres: the GET
// answers flat true with parent_id null on every node, the POST stores the
// submitted sort values (only the sort column) and answers the new order, a
// node with a parent is a 422 on nodes and a missing node a 409.
func TestReorderFlatSmoke(t *testing.T) {
e := newCatalogEnv(t)
ids := make([]uint, 0, 3)
for i, name := range []string{"One", "Two", "Three"} {
badge := catalogBadge{Name: name, SortOrder: i + 1}
if err := e.db.Create(&badge).Error; err != nil {
t.Fatal(err)
}
ids = append(ids, badge.ID)
}
rec := e.send(t, http.MethodGet, "/acme/catalog/badges/reorder", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("reorder GET status=%d body=%s", rec.Code, rec.Body.String())
}
var got catalogReorder
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Data.Flat || len(got.Data.Nodes) != 3 || got.Data.Nodes[0].Name != "One" {
t.Fatalf("reorder GET = %s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"flat":true`) {
t.Fatalf("reorder GET lacks flat: %s", rec.Body.String())
}
for _, node := range got.Data.Nodes {
if node.ParentID != nil {
t.Fatalf("flat node %d has parent %d", node.ID, *node.ParentID)
}
}
reversed := []map[string]any{
{"id": ids[2], "parent_id": nil, "sort_order": 1},
{"id": ids[1], "parent_id": nil, "sort_order": 2},
{"id": ids[0], "parent_id": nil, "sort_order": 3},
}
rec = e.send(t, http.MethodPost, "/acme/catalog/badges/reorder", map[string]any{"nodes": reversed}, true)
if rec.Code != http.StatusOK {
t.Fatalf("reorder POST status=%d body=%s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got.Data.Nodes) != 3 || got.Data.Nodes[0].ID != ids[2] || got.Data.Nodes[2].ID != ids[0] || !got.Data.Flat {
t.Fatalf("reorder POST answer = %s", rec.Body.String())
}
for i, id := range []uint{ids[2], ids[1], ids[0]} {
var badge catalogBadge
if err := e.db.First(&badge, id).Error; err != nil {
t.Fatal(err)
}
if badge.SortOrder != i+1 {
t.Fatalf("badge %d sort_order = %d, want %d", id, badge.SortOrder, i+1)
}
}
nested := []map[string]any{
{"id": ids[0], "parent_id": nil, "sort_order": 1},
{"id": ids[1], "parent_id": ids[0], "sort_order": 2},
{"id": ids[2], "parent_id": nil, "sort_order": 3},
}
rec = e.send(t, http.MethodPost, "/acme/catalog/badges/reorder", map[string]any{"nodes": nested}, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"nodes"`) || !strings.Contains(rec.Body.String(), "Kolejność płaska nie ma elementów nadrzędnych.") {
t.Fatalf("flat parent status=%d body=%s", rec.Code, rec.Body.String())
}
rec = e.send(t, http.MethodPost, "/acme/catalog/badges/reorder", map[string]any{"nodes": reversed[:2]}, true)
if rec.Code != http.StatusConflict {
t.Fatalf("missing node status=%d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -1,12 +1,37 @@
package cabana
import (
"fmt"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"gorm.io/gorm"
)
// CompiledListSchema compiles the admin controllers of plugins without a
// database or config, as Activate does, and returns the list schema of the
// controller id (with its reorder object).
func CompiledListSchema(plugins []party.Plugin, id string) (*ListSchema, error) {
items, err := collectControllers(plugins)
if err != nil {
return nil, err
}
types, err := compileFieldTypes(plugins)
if err != nil {
return nil, err
}
reg, err := compileRegistryWithTypes(items, types)
if err != nil {
return nil, err
}
cc, ok := reg.Get(id)
if !ok || cc.List == nil {
return nil, fmt.Errorf("cabana: no list schema for %s", id)
}
return cc.List, nil
}
// AdminAPIRoutes exposes the admin API route inventory (method plus
// prefix-relative path) to the external conformance test.
func AdminAPIRoutes() []string {

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

View File

@@ -0,0 +1,79 @@
package cabana_test
import (
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/party"
)
// Smoke tests of the flat (WinterCMS Sortable) reorder (Phase 18.1 D-17),
// Docker-free: the inference rule and the flat flag. The Postgres routes are
// TestReorderFlatSmoke in catalog_smoke_test.go. Full unit coverage is plan
// 06. No application names appear in framework tests.
// TestReorderFlatCompile pins the flat inference: a config_reorder.yaml
// without parentColumn on a record without a parent_id column compiles as
// flat and the list schema says so; maxDepth on it and an explicit
// parentColumn that is not a column fail boot; the tree fixture still
// compiles as a tree whose list schema carries no flat key.
func TestReorderFlatCompile(t *testing.T) {
const cfg = "controllers/badges/config_reorder.yaml"
list, err := cabana.CompiledListSchema([]party.Plugin{catalogFSPlugin{fsys: catalogOverlay(t, nil)}}, "acme.catalog.badges")
if err != nil {
t.Fatal(err)
}
if list.Reorder == nil || !list.Reorder.Flat || list.Reorder.Title != "Reorder badges" {
t.Fatalf("badges reorder = %+v", list.Reorder)
}
raw, err := json.Marshal(list.Reorder)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"flat":true`) {
t.Fatalf("flat list schema JSON: %s", raw)
}
if err := activateCatalog(t, catalogFSPlugin{fsys: catalogOverlay(t, nil)}); err != nil {
t.Fatalf("catalog boot: %v", err)
}
src := catalogFile(t, cfg)
for _, tc := range []struct {
name, add string
want []string
}{
{"maxDepth on a flat reorder", "maxDepth: 2\n", []string{cfg, "maxDepth needs a tree reorder"}},
{"an explicit parentColumn that is not a column", "parentColumn: parent_id\n", []string{cfg, "parentColumn parent_id is not a column of the record"}},
} {
t.Run(tc.name, func(t *testing.T) {
err := activateCatalog(t, catalogFSPlugin{fsys: catalogOverlay(t, map[string]string{cfg: src + tc.add})})
if err == nil {
t.Fatal("boot accepted the reorder config")
}
for _, part := range tc.want {
if !strings.Contains(err.Error(), part) {
t.Fatalf("err = %v, want %q", err, part)
}
}
})
}
t.Run("the tree fixture stays a tree", func(t *testing.T) {
tree, err := cabana.CompiledListSchema([]party.Plugin{companionsFSPlugin{fsys: companionsOverlay(t, nil)}}, "acme.companions.links")
if err != nil {
t.Fatal(err)
}
if tree.Reorder == nil || tree.Reorder.Flat {
t.Fatalf("links reorder = %+v", tree.Reorder)
}
raw, err := json.Marshal(tree)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `"flat"`) {
t.Fatalf("tree list schema carries a flat key: %s", raw)
}
})
}

View File

@@ -4,4 +4,4 @@ title: Badges
recordUrl: acme/catalog/badges/update/:id
recordsPerPage: 20
toolbar:
buttons: [create]
buttons: [create, reorder]

View File

@@ -0,0 +1,3 @@
title: Reorder badges
nameFrom: name
modelClass: Badge

View File

@@ -399,6 +399,7 @@ repeater:
remove_confirm: "Remove “:title”? The change is saved together with the record."
reorder:
stale: The records changed in the meantime. The list has been reloaded.
flat_parent: A flat reorder has no parents.
load_failed: The records could not be loaded. Refresh the page to try again.
failed: The new order could not be saved. The previous order is back.
saved: Order saved

View File

@@ -427,6 +427,7 @@ repeater:
remove_confirm: "Usunąć „:title”? Zmiana zostanie zapisana razem z rekordem."
reorder:
stale: Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie.
flat_parent: Kolejność płaska nie ma elementów nadrzędnych.
load_failed: Nie udało się wczytać rekordów. Odśwież stronę, aby spróbować ponownie.
failed: Nie udało się zapisać nowej kolejności. Przywrócono poprzednią.
saved: Zapisano kolejność