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

@@ -2628,6 +2628,106 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Create a related record
* @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.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
id: number;
/** @description Relation name */
name: string;
};
cookie?: never;
};
/** @description Field values of the manage form keyed by field name */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminRecord"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.RecordEnvelope"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
parameters: {
query?: never;
@@ -2893,6 +2993,12 @@ export interface components {
comment?: string;
context?: components["schemas"]["cabana.fieldContext"];
default?: components["schemas"]["cabana.jsonScalar"];
/**
* @description Deferrable is true on a relation-manager field whose relation can be
* managed before the record is first saved (RelationSchema.Deferrable):
* the SPA shows it on the create screen.
*/
deferrable?: boolean;
displayFormat?: string;
emptyOption?: string;
/** @description FileTypes are the allowed lower-case extensions of a fileupload field. */
@@ -3143,13 +3249,30 @@ export interface components {
};
"cabana.RelationMessages": {
candidateSearch: components["schemas"]["cabana.MessageForms"];
create: components["schemas"]["cabana.MessageForms"];
createSubmit: components["schemas"]["cabana.MessageForms"];
createTitle: components["schemas"]["cabana.MessageForms"];
created: components["schemas"]["cabana.MessageForms"];
deleteConfirm: components["schemas"]["cabana.MessageForms"];
deleteOneConfirm: components["schemas"]["cabana.MessageForms"];
deleteSelected: components["schemas"]["cabana.MessageForms"];
deleted: components["schemas"]["cabana.MessageForms"];
editPivot: components["schemas"]["cabana.MessageForms"];
empty: components["schemas"]["cabana.MessageForms"];
link: components["schemas"]["cabana.MessageForms"];
linkHint: components["schemas"]["cabana.MessageForms"];
linkSubmit: components["schemas"]["cabana.MessageForms"];
linked: components["schemas"]["cabana.MessageForms"];
pivotSaved: components["schemas"]["cabana.MessageForms"];
pivotSubmit: components["schemas"]["cabana.MessageForms"];
pivotTitle: components["schemas"]["cabana.MessageForms"];
previewTitle: components["schemas"]["cabana.MessageForms"];
unlinkConfirm: components["schemas"]["cabana.MessageForms"];
unlinkSelected: components["schemas"]["cabana.MessageForms"];
unlinked: components["schemas"]["cabana.MessageForms"];
updateSubmit: components["schemas"]["cabana.MessageForms"];
updateTitle: components["schemas"]["cabana.MessageForms"];
updated: components["schemas"]["cabana.MessageForms"];
};
"cabana.RelationMutationResult": {
linked?: number;
@@ -3165,15 +3288,32 @@ export interface components {
toolbarButtons: string[];
};
"cabana.RelationSchema": {
/**
* @description Deferrable is true when the relation can be managed on a record that
* is not saved yet: always for belongsToMany, and for a hasMany whose
* ForeignKey is nullable.
*/
deferrable: boolean;
/** @description Kind is the contract kind: belongsToMany or hasMany. */
kind: string;
label: string;
manage: components["schemas"]["cabana.RelationPanel"];
/**
* @description ManageForm is the child create and update form (manage.form, or the
* top-level form); ViewForm is the read-only preview form (view.form,
* or the top-level form); PivotForm edits pivot columns of a
* belongsToMany link (pivot.form). Each is omitted when not declared.
*/
manageForm?: components["schemas"]["cabana.FormField"][];
/**
* @description Messages is the relation manager's copy (D-13); the cached schema
* carries each phrase key as its own form.
*/
messages: components["schemas"]["cabana.RelationMessages"];
name: string;
pivotForm?: components["schemas"]["cabana.FormField"][];
view: components["schemas"]["cabana.RelationPanel"];
viewForm?: components["schemas"]["cabana.FormField"][];
};
"cabana.RowAction": {
label?: string;