diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 170cefb..8f0a621 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -520,6 +520,10 @@ }, "cabana.AdminReorderTree": { "properties": { + "flat": { + "description": "Flat is true for a flat reorder (WinterCMS Sortable): every node's\nparent_id is null and a save writes only the sort column. Omitted for\na tree reorder.", + "type": "boolean" + }, "nodes": { "items": { "$ref": "#/components/schemas/cabana.AdminReorderNode" @@ -1945,6 +1949,10 @@ }, "cabana.ListReorder": { "properties": { + "flat": { + "description": "Flat is true for a flat reorder (WinterCMS Sortable): the record has\nno parent column, so the records are only put in order, never nested.\nOmitted for a tree reorder.", + "type": "boolean" + }, "partition": { "$ref": "#/components/schemas/cabana.ListReorderPartition" }, @@ -4814,7 +4822,7 @@ }, "/{vendor}/{plugin}/{controller}/reorder": { "get": { - "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.", "parameters": [ { "description": "Vendor", @@ -4915,7 +4923,7 @@ ] }, "post": { - "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.", "parameters": [ { "description": "Vendor", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index baba22a..821cc80 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -1902,7 +1902,7 @@ export interface paths { }; /** * 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. */ get: { parameters: { @@ -1973,7 +1973,7 @@ export interface paths { put?: never; /** * 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. */ post: { parameters: { @@ -5391,6 +5391,12 @@ export interface components { partition: number; }; "cabana.AdminReorderTree": { + /** + * @description 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?: boolean; nodes: components["schemas"]["cabana.AdminReorderNode"][]; partition: components["schemas"]["cabana.AdminReorderPartition"]; title: string; @@ -5889,6 +5895,12 @@ export interface components { total: number; }; "cabana.ListReorder": { + /** + * @description 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?: boolean; partition?: components["schemas"]["cabana.ListReorderPartition"]; title: string; }; diff --git a/docs/backend/lists-and-filters.md b/docs/backend/lists-and-filters.md index ea8d44a..00dc2a4 100644 --- a/docs/backend/lists-and-filters.md +++ b/docs/backend/lists-and-filters.md @@ -189,7 +189,7 @@ A list that shows soft-deleted records includes them in its scope, as `withTrash ## Tree reorder -A controller whose records form a tree (a `parent_id` and a `sort_order` column, such as menu entries) gets a drag-and-drop reorder page, the successor of WinterCMS's ReorderController. It needs a `config_reorder.yaml` next to `config_list.yaml` and the built-in `reorder` button in `toolbar.buttons`: +A controller whose records form a tree (a `parent_id` and a `sort_order` column, such as menu entries) gets a drag-and-drop reorder page, the successor of WinterCMS's ReorderController. Records without a parent column get the same page as a plain ordered list; see [Flat reorder](#flat-reorder). It needs a `config_reorder.yaml` next to `config_list.yaml` and the built-in `reorder` button in `toolbar.buttons`: ```yaml src=modules/cabana/testdata/companions/controllers/links/config_reorder.yaml title: Reorder links @@ -209,7 +209,7 @@ partition: | `title` | The page title, a translation key or text. | | `nameFrom` | The column a node shows. | | `modelClass` | Optional; when set it must equal the controller's model name, as in WinterCMS's `reorder_config.yaml`. | -| `parentColumn`, `sortColumn` | The tree columns, `parent_id` and `sort_order` by default. Both are written by a save. | +| `parentColumn`, `sortColumn` | The tree columns, `parent_id` and `sort_order` by default. Both are written by a save. Without `parentColumn` on a record that has no `parent_id` column the reorder is [flat](#flat-reorder). | | `maxDepth` | The deepest level a node may sit at, counting the top level as 1; 0 (the default) is unlimited. | | `partition` | Optional: reorder within one value of a column, such as the entries of one menu. `column` is that column, `relation` the form's `type: relation` belongsTo field over it (its options, scoped by `pact.RelationExtendOptionsQuery`, are the partitions offered), `nameFrom` an optional label column of the related record and `label` the selector's label. | @@ -227,6 +227,20 @@ Both routes need the controller's permissions; the save is a write route (CSRF h In the admin, a list whose schema carries `reorder` shows a "Reorder records" button before "New record". It opens the reorder page, and when the list is filtered on the partition column it opens that partition. The page shows the tree with a drag handle and move up, move down, outdent and indent buttons on every row. A row can also be dragged before, after or into another row (never into its own subtree), and the arrow keys on a focused handle move it the same way as the buttons. There is no Save button: 400 ms after the last move the page posts the whole set, shows "Order saved", and sends a pending save at once when the administrator leaves. Only one save is in flight at a time: moves made while it runs are sent after it settles, with the tree as it is then, so the stored order never ends older than the screen. A refused save puts the last saved order back, unless a later save is waiting (it carries the whole tree); a 409 reloads the tree. A save that settles after the page switched partition does not touch the new partition's tree. A partitioned page has a selector for the partition. +## Flat reorder + +A record with an order but no parent (WinterCMS's Sortable trait: a `sort_order` column and no `parent_id`) uses the same `config_reorder.yaml` without `parentColumn`. A WinterCMS `reorder_config.yaml` of such a controller compiles unchanged: + +```yaml src=modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml +title: Reorder badges +nameFrom: name +modelClass: Badge +``` + +The reorder is flat when the file names no `parentColumn` and the controller's record has no `parent_id` column, as WinterCMS infers a Sortable model. An explicit `parentColumn` always means a tree and still stops the start-up when it is not a column, and a record with a `parent_id` column keeps the tree reorder. `nameFrom`, `sortColumn`, `modelClass` and `partition` work as on a tree; `maxDepth` above 0 on a flat reorder stops the start-up. + +The list schema's `reorder` and the data of both reorder routes carry `flat: true`; a tree reorder omits the key, so its payloads are unchanged. Every node of a flat reorder has `parent_id: null`. A save writes only the sort column, without running the model's save hooks. A node whose `parent_id` is not null is a 422 `validation_failed` on `nodes` with `reorder.flat_parent`; the duplicate-id 422 and the 409 `reorder_stale` set rule are the same as on a tree. + ## Remembered list state The admin keeps a list's search, sort, filters, page and page size in the URL, so a filtered list can be bookmarked or shared. It also remembers the last of these per list in the browser's local storage. When an administrator opens a list without any of these parameters in the URL (from the navigation, a breadcrumb or a form's back, save or delete), the admin restores the remembered query. A URL that carries list parameters wins and replaces the remembered query. diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 2c999b5..59d378a 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -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: ` 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[]=` 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. | diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 9baf6b4..7b5c399 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -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 diff --git a/modules/cabana/catalog_smoke_test.go b/modules/cabana/catalog_smoke_test.go index 68a5739..0b2aaba 100644 --- a/modules/cabana/catalog_smoke_test.go +++ b/modules/cabana/catalog_smoke_test.go @@ -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()) + } +} diff --git a/modules/cabana/export_test.go b/modules/cabana/export_test.go index 1a62348..ab4bd6b 100644 --- a/modules/cabana/export_test.go +++ b/modules/cabana/export_test.go @@ -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 { diff --git a/modules/cabana/reorder.go b/modules/cabana/reorder.go index 0613d37..4a41e8b 100644 --- a/modules/cabana/reorder.go +++ b/modules/cabana/reorder.go @@ -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 diff --git a/modules/cabana/reorder_flat_test.go b/modules/cabana/reorder_flat_test.go new file mode 100644 index 0000000..f3c991e --- /dev/null +++ b/modules/cabana/reorder_flat_test.go @@ -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) + } + }) +} diff --git a/modules/cabana/testdata/catalog/controllers/badges/config_list.yaml b/modules/cabana/testdata/catalog/controllers/badges/config_list.yaml index 6ac53e5..8733a99 100644 --- a/modules/cabana/testdata/catalog/controllers/badges/config_list.yaml +++ b/modules/cabana/testdata/catalog/controllers/badges/config_list.yaml @@ -4,4 +4,4 @@ title: Badges recordUrl: acme/catalog/badges/update/:id recordsPerPage: 20 toolbar: - buttons: [create] + buttons: [create, reorder] diff --git a/modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml b/modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml new file mode 100644 index 0000000..65fdc88 --- /dev/null +++ b/modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml @@ -0,0 +1,3 @@ +title: Reorder badges +nameFrom: name +modelClass: Badge diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index cb0f892..4b8e0d9 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -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 diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index bcbd26d..07ad6cf 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -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ść