feat(12.2-03): add hasMany relation contracts, relation forms and child create
- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Relation manager
|
||||
description: Edit belongsTo and belongsToMany relations in admin forms and manage linked records with config_relation.yaml, bound to models the controller names.
|
||||
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
|
||||
---
|
||||
@@ -49,7 +49,79 @@ editors:
|
||||
|
||||
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. The `view` panel's `toolbarButtons` decide which of the two the server accepts: a relation that does not list `unlink` answers 403 `forbidden` on the unlink route, and likewise for `link`. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written.
|
||||
`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.
|
||||
|
||||
A controller can hook into the child writes with the optional `pact.RelationBeforeCreate` and `pact.RelationAfterCreate` (and the update and delete pairs), 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.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user