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:
Jakub Zych
2026-10-02 18:37:13 +02:00
parent 0b4ef9c311
commit 48a5b8045a
23 changed files with 1621 additions and 100 deletions

View File

@@ -615,6 +615,10 @@
"default": {
"$ref": "#/components/schemas/cabana.jsonScalar"
},
"deferrable": {
"description": "Deferrable is true on a relation-manager field whose relation can be\nmanaged before the record is first saved (RelationSchema.Deferrable):\nthe SPA shows it on the create screen.",
"type": "boolean"
},
"displayFormat": {
"type": "string"
},
@@ -1380,6 +1384,33 @@
"candidateSearch": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"create": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"createSubmit": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"createTitle": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"created": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleteConfirm": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleteOneConfirm": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleteSelected": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleted": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"editPivot": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"empty": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
@@ -1389,9 +1420,24 @@
"linkHint": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"linkSubmit": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"linked": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"pivotSaved": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"pivotSubmit": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"pivotTitle": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"previewTitle": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"unlinkConfirm": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
@@ -1400,17 +1446,43 @@
},
"unlinked": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"updateSubmit": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"updateTitle": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"updated": {
"$ref": "#/components/schemas/cabana.MessageForms"
}
},
"required": [
"candidateSearch",
"create",
"createSubmit",
"createTitle",
"created",
"deleteConfirm",
"deleteOneConfirm",
"deleteSelected",
"deleted",
"editPivot",
"empty",
"link",
"linkHint",
"linkSubmit",
"linked",
"pivotSaved",
"pivotSubmit",
"pivotTitle",
"previewTitle",
"unlinkConfirm",
"unlinkSelected",
"unlinked"
"unlinked",
"updateSubmit",
"updateTitle",
"updated"
],
"type": "object"
},
@@ -1464,12 +1536,27 @@
},
"cabana.RelationSchema": {
"properties": {
"deferrable": {
"description": "Deferrable is true when the relation can be managed on a record that\nis not saved yet: always for belongsToMany, and for a hasMany whose\nForeignKey is nullable.",
"type": "boolean"
},
"kind": {
"description": "Kind is the contract kind: belongsToMany or hasMany.",
"type": "string"
},
"label": {
"type": "string"
},
"manage": {
"$ref": "#/components/schemas/cabana.RelationPanel"
},
"manageForm": {
"description": "ManageForm is the child create and update form (manage.form, or the\ntop-level form); ViewForm is the read-only preview form (view.form,\nor the top-level form); PivotForm edits pivot columns of a\nbelongsToMany link (pivot.form). Each is omitted when not declared.",
"items": {
"$ref": "#/components/schemas/cabana.FormField"
},
"type": "array"
},
"messages": {
"allOf": [
{
@@ -1481,11 +1568,25 @@
"name": {
"type": "string"
},
"pivotForm": {
"items": {
"$ref": "#/components/schemas/cabana.FormField"
},
"type": "array"
},
"view": {
"$ref": "#/components/schemas/cabana.RelationPanel"
},
"viewForm": {
"items": {
"$ref": "#/components/schemas/cabana.FormField"
},
"type": "array"
}
},
"required": [
"deferrable",
"kind",
"label",
"manage",
"messages",
@@ -5104,6 +5205,140 @@
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": {
"post": {
"description": "Creates a record through the relation's manage form (manage.form, or the top-level form of config_relation.yaml) and attaches it to the owner: a hasMany child gets the owner's key in its foreign key (the server sets it; the body cannot), a belongsToMany record gets a pivot row. The view panel must declare the create toolbar button, otherwise 403. The owner is scoped like the record show route.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Owner id",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "Relation name",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminRecord"
}
}
},
"description": "Field values of the manage form keyed by field name",
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.RecordEnvelope"
}
}
},
"description": "Created"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Request Entity Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Create a related record",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
"post": {
"parameters": [