- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
182 lines
15 KiB
Markdown
182 lines
15 KiB
Markdown
---
|
|
title: Relation manager
|
|
description: Edit belongsTo and belongsToMany fields, and manage linked and hasMany records with config_relation.yaml, bound to models the controller names.
|
|
section: backend
|
|
order: 40
|
|
---
|
|
# Relation manager
|
|
|
|
WinterCMS edits relations in two ways: a `relation` form field that picks the related record, and the Relation behaviour, which embeds a list of linked records with link and unlink buttons. [cabana](../../modules/cabana/README.md) has both. The one rule that differs from WinterCMS: the framework never guesses a table, pivot or foreign key name. The controller supplies every name, and a missing or wrong one stops the start-up.
|
|
|
|
## Relation fields
|
|
|
|
A `type: relation` field in `fields.yaml` picks a belongsTo record or a set of belongsToMany records. `nameFrom` names the related model's label column:
|
|
|
|
```yaml
|
|
category:
|
|
label: acme.blog::lang.posts.category
|
|
type: relation
|
|
nameFrom: name
|
|
```
|
|
|
|
The controller implements `cabana.FieldRelationProvider` and returns a `cabana.FieldRelationContract` per field: its `Kind` (`belongsTo` or `belongsToMany`), a factory for the related model, and the `ForeignKey` of a belongsTo or the pivot model and its two key columns for a belongsToMany. An optional `OrderColumn` on the pivot stores the order in which the administrator picked the records.
|
|
|
|
The SPA loads the choices, paginated, from `.../fields/{field}/options`, and every record response carries the display labels of the linked records. A controller that implements `pact.RelationExtendOptionsQuery` narrows the choices, and the same scoped query rechecks the submitted IDs on save, so a record it does not offer cannot be attached.
|
|
|
|
## Relation managers
|
|
|
|
A `type: relation-manager` field embeds a relation manager in the form. Its `relation` key names an entry in the controller's `config_relation.yaml`, which describes the two panels: the linked records (`view`) and the candidates shown when linking (`manage`):
|
|
|
|
```yaml
|
|
editors:
|
|
label: acme.blog::lang.posts.editors
|
|
view:
|
|
list:
|
|
columns:
|
|
name:
|
|
label: acme.blog::lang.editors.name
|
|
email:
|
|
label: acme.blog::lang.editors.email
|
|
toolbarButtons: link|unlink
|
|
showSearch: true
|
|
manage:
|
|
list:
|
|
columns:
|
|
name:
|
|
label: acme.blog::lang.editors.name
|
|
showSearch: true
|
|
```
|
|
|
|
The controller implements `cabana.AdminRelationContractProvider` and returns a `cabana.RelationContract` per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a `relation-manager` field stops the start-up.
|
|
|
|
`cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written.
|
|
|
|
### Relation kinds
|
|
|
|
`RelationContract.Kind` says how the related records hang off the parent:
|
|
|
|
- `belongsToMany` (`cabana.RelationBelongsToMany`) links records through a pivot model: `NewPivot`, `ParentForeignKey`, `RelatedForeignKey` and optionally `HookPivotColumns`. A contract that leaves `Kind` empty is a belongsToMany, so contracts written before hasMany existed keep working unchanged.
|
|
- `hasMany` (`cabana.RelationHasMany`) owns records through a column on the related model: `ForeignKey` names it. A hasMany contract declares no pivot fields; a pivot field, a `ForeignKey` that is not an integer column of the related model, or an unknown kind stops the start-up.
|
|
|
|
```go src=modules/cabana/example_relation_test.go#PostsController.AdminRelationContracts
|
|
// AdminRelationContracts binds the posts form's relation managers to their
|
|
// models; the framework never guesses a table or column.
|
|
func (PostsController) AdminRelationContracts() []cabana.RelationContract {
|
|
return []cabana.RelationContract{{
|
|
Name: "comments",
|
|
Kind: cabana.RelationHasMany,
|
|
NewRelated: func() any { return &Comment{} },
|
|
ForeignKey: "post_id",
|
|
Columns: map[string]string{"author": "author", "body": "body"},
|
|
}}
|
|
}
|
|
```
|
|
|
|
A hasMany `ForeignKey` of a pointer type (`*uint`, a nullable column) is needed for `unlink` and for managing the relation before the parent is saved.
|
|
|
|
### Relation forms
|
|
|
|
The relation manager creates and edits related records in a modal through a form of their own. `manage.form` names the form used to create and update a child, `view.form` the read-only preview; a top-level `form` is the fallback of both, as in WinterCMS:
|
|
|
|
```yaml
|
|
comments:
|
|
label: acme.blog::lang.posts.comments
|
|
form: $/acme/blog/models/comment/fields.yaml
|
|
view:
|
|
list:
|
|
columns:
|
|
author:
|
|
label: acme.blog::lang.comments.author
|
|
toolbarButtons: create|update|delete
|
|
manage:
|
|
list:
|
|
columns:
|
|
author:
|
|
label: acme.blog::lang.comments.author
|
|
```
|
|
|
|
A form path is plugin-relative, `~/plugins/<vendor>/<plugin>/...`, or WinterCMS's `$/<vendor>/<plugin>/...`. A `$/` path resolves inside the same plugin; a path into another plugin stops the start-up, because the plugin's embedded tree cannot read it. The form is compiled at boot like a controller form, against the related model: every field must be a column of that model, and the related model must implement `lagoon.HasFillable` and `Rules` when `create` or `update` is declared.
|
|
|
|
A relation form accepts the scalar field types (`text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown`) plus `datepicker` and `fileupload`. `relation`, `relation-manager`, `widget` and `partial` stop the start-up, and so does a field named like a hasMany `ForeignKey`: the server sets that column from the parent.
|
|
|
|
### Toolbar buttons
|
|
|
|
The `view` panel's `toolbarButtons` take WinterCMS's buttons, and each one is the capability of its routes: a route whose button the relation does not declare answers 403 `forbidden`, whatever the controller permission.
|
|
|
|
| Button | Allows |
|
|
|--------|--------|
|
|
| `create` | Creating a related record through the manage form. Needs a manage form. |
|
|
| `update` | Editing a related record. Needs a manage form. |
|
|
| `delete` | Deleting related records. |
|
|
| `link` | Linking existing records. |
|
|
| `unlink` | Unlinking records. On a hasMany it needs a nullable `ForeignKey`. |
|
|
|
|
The `manage` panel may declare only `link`. An unknown or duplicate button stops the start-up. One difference from WinterCMS: there, clicking a row opens the update form whatever the toolbar lists; here a row is editable only when `update` is listed.
|
|
|
|
### Creating related records
|
|
|
|
`POST .../{id}/relations/{name}/records` creates a related record from the manage form's fields and attaches it to the parent, in one transaction. The parent is loaded through `pact.FormExtendQuery`, so a parent the administrator cannot open answers 404. The child is filled and validated like a controller save (the related model's `Fill`, its rules merged with the form's `required` flags, a 422 `validation_failed` envelope on failure) and its GORM hooks run. On a hasMany the server sets the `ForeignKey` to the parent's key; a body that names that column cannot change it. On a belongsToMany the record is inserted and its pivot row written, with `pact.RelationBeforeLink` stamping the hook columns.
|
|
|
|
### Editing, deleting, linking and unlinking
|
|
|
|
| Route | Button | Effect |
|
|
|-------|--------|--------|
|
|
| GET `.../{id}/relations/{name}/records/{child}` | `update`, or a view form | One child, through the manage form when `update` is listed, else through the view form. |
|
|
| PUT `.../{id}/relations/{name}/records/{child}` | `update` | Saves the child through the manage form, with the related model's rules and hooks. |
|
|
| POST `.../{id}/relations/{name}/delete` | `delete` | `{ids}`: deletes the children. |
|
|
| POST `.../{id}/relations/{name}/link` | `link` | `{ids}`, optionally with `pivot`: links existing records. |
|
|
| POST `.../{id}/relations/{name}/unlink` | `unlink` | `{ids}`: unlinks records. |
|
|
|
|
What delete, link and unlink do depends on the kind:
|
|
|
|
- On a hasMany, `delete` deletes the child through its model, so its hooks and soft delete run. `link` adopts records whose `ForeignKey` is NULL, setting it to the parent's key through the child model; `unlink` sets it back to NULL.
|
|
- On a belongsToMany, `delete` removes this parent's pivot row and then deletes the related record through its model. `link` writes pivot rows and `unlink` deletes them, as before.
|
|
|
|
Every child route is scoped to its parent. The parent is loaded through `pact.FormExtendQuery`, and the child must belong to it: on a hasMany by its `ForeignKey`, on a belongsToMany by a pivot row. A child of another parent, or any child of a parent the administrator cannot open, answers 404 `not_found`, never 403, so a request cannot tell a foreign record from a missing one. A delete is all or nothing: when one of the ids is not a child of the parent, nothing is deleted.
|
|
|
|
A controller can hook into the child writes with the optional `pact.RelationBeforeCreate` and `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate` and `pact.RelationAfterUpdate`, and `pact.RelationBeforeDelete` and `pact.RelationAfterDelete`, each called with the relation name, the parent and the child. A hook error rolls the whole write back and answers the generic lifecycle error.
|
|
|
|
### Pivot forms
|
|
|
|
A belongsToMany relation can edit columns of its pivot rows with `pivot.form` (a pivot form on a hasMany stops the start-up):
|
|
|
|
```yaml
|
|
editors:
|
|
label: acme.blog::lang.posts.editors
|
|
pivot:
|
|
form: $/acme/blog/models/posteditor/pivot_fields.yaml
|
|
```
|
|
|
|
WinterCMS names pivot form fields `pivot[role]`; both `pivot[role]` and the bare `role` are accepted and compile to the pivot column `role`. Each field must be a scalar or `datepicker` column of the pivot model, and never one of the pivot's foreign keys, `id`, `created_at`, `updated_at`, `deleted_at` or a `HookPivotColumns` entry: those stay server-owned.
|
|
|
|
The SPA sends pivot values when it links one record: `{"ids": [7], "pivot": {"role": "reviewer"}}`. A `pivot` object with more than one id answers 422 on `ids`, and a key that is not a pivot form field answers 422 on that key. The values are filled into the pivot model through the pivot form's fields only, validated, and then `pact.RelationBeforeLink` stamps its hook columns as before. Later, GET and PUT `.../{id}/relations/{name}/pivot/{child}` read and save the same values on an existing link. Both need a pivot form and the `link` or `update` button, and a record not linked to the parent answers 404.
|
|
|
|
### Managers on the create screen
|
|
|
|
A relation manager also works on a record that is not saved yet, as WinterCMS's RelationController does with deferred binding. A relation is *deferrable* when it can hold its changes until the record exists: a belongsToMany always (the pivot rows are written on save), a hasMany only with a nullable `ForeignKey`. The relation schema and the `relation-manager` form field carry `deferrable`, and the SPA shows deferrable managers on the create screen.
|
|
|
|
On the create screen the record id in every relation route is `0`, and the SPA sends its form session key in `X-Session-Key`, the same key the record's file uploads use. Id 0 is accepted only for a deferrable relation, with a valid key, on a controller that declares create, when the `relation-manager` field is not hidden from the create context; otherwise it is 404. The work is held in `deferred_bindings` against the key and the signed-in administrator:
|
|
|
|
- Creating a child inserts it at once (a hasMany child with a NULL `ForeignKey`) and binds it to the session, marked as created.
|
|
- Linking binds existing records, with any pivot values, after the same eligibility checks as a link on a saved record. A record another session created is never a candidate, so it cannot be adopted before its own form is saved.
|
|
- Unlinking and deleting cancel a pending bind; a child the session created is deleted.
|
|
- The linked list shows the session's pending records, and the child and pivot routes read and edit them.
|
|
|
|
The record's first save with the same `X-Session-Key` applies all of it inside its transaction, in the order it happened: hasMany children get the new record's key, belongsToMany links get their pivot rows with the stored pivot values and `pact.RelationBeforeLink` stamps. A linked record is checked again with the saved record, because `ExcludedRelatedIDs` and `pact.RelationExtendManageQuery` saw a record without a key when it was linked; if it is no longer eligible the save answers 422 on the `relation-manager` field, nothing is saved, and the pending work stays for the next attempt. Pending work of another administrator's key is never read.
|
|
|
|
Nothing has to cancel an abandoned form. `deferred:purge` (also run daily by the scheduler) removes expired bindings and deletes the children they created. A deferrable relation that declares `create` therefore needs its related model in some plugin's `pact.HasModels` `Models()` list; otherwise the start-up stops, because the purge could not delete those children.
|
|
|
|
Existing belongsToMany managers become deferrable too. A manager without `context: update` on its `relation-manager` field now appears on the create screen; keep `context: update` to show it only after the first save.
|
|
|
|
### Files in child forms
|
|
|
|
A `fileupload` field in a relation's manage form works inside the child modal through its own routes under `.../{id}/relations/{name}/records/{child}/files/{field}`: list, upload, caption, remove, reorder, download and thumb, as for a record's own files. The child modal has its own form session key, sent in `X-Child-Session-Key`; the child's create or update with the same header attaches the files in the child's transaction. `{child}` 0 is the child being created and needs the `create` button and the child key; a saved child is scoped to the parent like the other child routes and needs `update` to change files. On a record that is not saved yet, the modal sends both headers: `X-Session-Key` for the parent and `X-Child-Session-Key` for the child.
|
|
|
|
### Messages
|
|
|
|
A relation's `messages` block overrides the relation manager's copy, each key a phrase key; an omitted key falls back to `backend::lang.messages.relation.<key in snake_case>`. The keys are `link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked` and `empty` for the link panels, and `create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit` and `linkSubmit` for the child and pivot modals. A key that names a missing phrase stops the start-up.
|
|
|
|
## Relations in lists
|
|
|
|
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.
|