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:
@@ -615,6 +615,10 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"$ref": "#/components/schemas/cabana.jsonScalar"
|
"$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": {
|
"displayFormat": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1380,6 +1384,33 @@
|
|||||||
"candidateSearch": {
|
"candidateSearch": {
|
||||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
"$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": {
|
"empty": {
|
||||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
},
|
},
|
||||||
@@ -1389,9 +1420,24 @@
|
|||||||
"linkHint": {
|
"linkHint": {
|
||||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
},
|
},
|
||||||
|
"linkSubmit": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
"linked": {
|
"linked": {
|
||||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
"$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": {
|
"unlinkConfirm": {
|
||||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
},
|
},
|
||||||
@@ -1400,17 +1446,43 @@
|
|||||||
},
|
},
|
||||||
"unlinked": {
|
"unlinked": {
|
||||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"updateSubmit": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"updateTitle": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"updated": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"candidateSearch",
|
"candidateSearch",
|
||||||
|
"create",
|
||||||
|
"createSubmit",
|
||||||
|
"createTitle",
|
||||||
|
"created",
|
||||||
|
"deleteConfirm",
|
||||||
|
"deleteOneConfirm",
|
||||||
|
"deleteSelected",
|
||||||
|
"deleted",
|
||||||
|
"editPivot",
|
||||||
"empty",
|
"empty",
|
||||||
"link",
|
"link",
|
||||||
"linkHint",
|
"linkHint",
|
||||||
|
"linkSubmit",
|
||||||
"linked",
|
"linked",
|
||||||
|
"pivotSaved",
|
||||||
|
"pivotSubmit",
|
||||||
|
"pivotTitle",
|
||||||
|
"previewTitle",
|
||||||
"unlinkConfirm",
|
"unlinkConfirm",
|
||||||
"unlinkSelected",
|
"unlinkSelected",
|
||||||
"unlinked"
|
"unlinked",
|
||||||
|
"updateSubmit",
|
||||||
|
"updateTitle",
|
||||||
|
"updated"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -1464,12 +1536,27 @@
|
|||||||
},
|
},
|
||||||
"cabana.RelationSchema": {
|
"cabana.RelationSchema": {
|
||||||
"properties": {
|
"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": {
|
"label": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"manage": {
|
"manage": {
|
||||||
"$ref": "#/components/schemas/cabana.RelationPanel"
|
"$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": {
|
"messages": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -1481,11 +1568,25 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"pivotForm": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/cabana.FormField"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"$ref": "#/components/schemas/cabana.RelationPanel"
|
"$ref": "#/components/schemas/cabana.RelationPanel"
|
||||||
|
},
|
||||||
|
"viewForm": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/cabana.FormField"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"deferrable",
|
||||||
|
"kind",
|
||||||
"label",
|
"label",
|
||||||
"manage",
|
"manage",
|
||||||
"messages",
|
"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": {
|
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
|
||||||
"post": {
|
"post": {
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
|||||||
140
admin/src/api/schema.d.ts
vendored
140
admin/src/api/schema.d.ts
vendored
@@ -2628,6 +2628,106 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2893,6 +2993,12 @@ export interface components {
|
|||||||
comment?: string;
|
comment?: string;
|
||||||
context?: components["schemas"]["cabana.fieldContext"];
|
context?: components["schemas"]["cabana.fieldContext"];
|
||||||
default?: components["schemas"]["cabana.jsonScalar"];
|
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;
|
displayFormat?: string;
|
||||||
emptyOption?: string;
|
emptyOption?: string;
|
||||||
/** @description FileTypes are the allowed lower-case extensions of a fileupload field. */
|
/** @description FileTypes are the allowed lower-case extensions of a fileupload field. */
|
||||||
@@ -3143,13 +3249,30 @@ export interface components {
|
|||||||
};
|
};
|
||||||
"cabana.RelationMessages": {
|
"cabana.RelationMessages": {
|
||||||
candidateSearch: components["schemas"]["cabana.MessageForms"];
|
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"];
|
empty: components["schemas"]["cabana.MessageForms"];
|
||||||
link: components["schemas"]["cabana.MessageForms"];
|
link: components["schemas"]["cabana.MessageForms"];
|
||||||
linkHint: components["schemas"]["cabana.MessageForms"];
|
linkHint: components["schemas"]["cabana.MessageForms"];
|
||||||
|
linkSubmit: components["schemas"]["cabana.MessageForms"];
|
||||||
linked: 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"];
|
unlinkConfirm: components["schemas"]["cabana.MessageForms"];
|
||||||
unlinkSelected: components["schemas"]["cabana.MessageForms"];
|
unlinkSelected: components["schemas"]["cabana.MessageForms"];
|
||||||
unlinked: 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": {
|
"cabana.RelationMutationResult": {
|
||||||
linked?: number;
|
linked?: number;
|
||||||
@@ -3165,15 +3288,32 @@ export interface components {
|
|||||||
toolbarButtons: string[];
|
toolbarButtons: string[];
|
||||||
};
|
};
|
||||||
"cabana.RelationSchema": {
|
"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;
|
label: string;
|
||||||
manage: components["schemas"]["cabana.RelationPanel"];
|
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
|
* @description Messages is the relation manager's copy (D-13); the cached schema
|
||||||
* carries each phrase key as its own form.
|
* carries each phrase key as its own form.
|
||||||
*/
|
*/
|
||||||
messages: components["schemas"]["cabana.RelationMessages"];
|
messages: components["schemas"]["cabana.RelationMessages"];
|
||||||
name: string;
|
name: string;
|
||||||
|
pivotForm?: components["schemas"]["cabana.FormField"][];
|
||||||
view: components["schemas"]["cabana.RelationPanel"];
|
view: components["schemas"]["cabana.RelationPanel"];
|
||||||
|
viewForm?: components["schemas"]["cabana.FormField"][];
|
||||||
};
|
};
|
||||||
"cabana.RowAction": {
|
"cabana.RowAction": {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"name": "members",
|
"name": "members",
|
||||||
"label": "Members",
|
"label": "Members",
|
||||||
|
"kind": "belongsToMany",
|
||||||
|
"deferrable": true,
|
||||||
"view": {
|
"view": {
|
||||||
"list": {
|
"list": {
|
||||||
"columns": [
|
"columns": [
|
||||||
@@ -72,6 +74,58 @@
|
|||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"other": "This widget has no members."
|
"other": "This widget has no members."
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"other": "New record"
|
||||||
|
},
|
||||||
|
"createTitle": {
|
||||||
|
"other": "New record"
|
||||||
|
},
|
||||||
|
"updateTitle": {
|
||||||
|
"other": "Edit record"
|
||||||
|
},
|
||||||
|
"previewTitle": {
|
||||||
|
"other": "Record preview"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"other": "Record created"
|
||||||
|
},
|
||||||
|
"updated": {
|
||||||
|
"other": "Record saved"
|
||||||
|
},
|
||||||
|
"deleteSelected": {
|
||||||
|
"other": "Delete selected"
|
||||||
|
},
|
||||||
|
"deleteConfirm": {
|
||||||
|
"other": "Delete the selected (:count)? This cannot be undone."
|
||||||
|
},
|
||||||
|
"deleteOneConfirm": {
|
||||||
|
"other": "Delete this record? This cannot be undone."
|
||||||
|
},
|
||||||
|
"deleted": {
|
||||||
|
"one": "Deleted :count record",
|
||||||
|
"other": "Deleted :count records"
|
||||||
|
},
|
||||||
|
"pivotTitle": {
|
||||||
|
"other": "Link details"
|
||||||
|
},
|
||||||
|
"pivotSaved": {
|
||||||
|
"other": "Link details saved"
|
||||||
|
},
|
||||||
|
"editPivot": {
|
||||||
|
"other": "Edit link details: :name"
|
||||||
|
},
|
||||||
|
"createSubmit": {
|
||||||
|
"other": "Create record"
|
||||||
|
},
|
||||||
|
"updateSubmit": {
|
||||||
|
"other": "Save record"
|
||||||
|
},
|
||||||
|
"pivotSubmit": {
|
||||||
|
"other": "Save link details"
|
||||||
|
},
|
||||||
|
"linkSubmit": {
|
||||||
|
"other": "Add link"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Relation manager
|
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
|
section: backend
|
||||||
order: 40
|
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.
|
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
|
## Relations in lists
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
|||||||
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
|
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
|
||||||
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
||||||
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
||||||
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
|
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
|
||||||
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
||||||
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
||||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
||||||
@@ -48,6 +48,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
|||||||
| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
|
| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
|
||||||
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
|
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
|
||||||
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. |
|
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. |
|
||||||
|
| POST `.../{id}/relations/{name}/records` | Create a related record through the relation's manage form and attach it to the record (a hasMany foreign key is set by the server; a belongsToMany gets its pivot row). Needs `create` in the view panel's `toolbarButtons` (403 otherwise). |
|
||||||
| GET `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. |
|
| GET `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. |
|
||||||
| POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. |
|
| POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. |
|
||||||
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
|
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
|
||||||
@@ -163,6 +164,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
|||||||
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |
|
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |
|
||||||
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. |
|
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. |
|
||||||
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
|
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
|
||||||
|
| `cabana.RelationBelongsToMany` / `cabana.RelationHasMany` | The two `RelationContract.Kind` values; an empty `Kind` is a belongsToMany. |
|
||||||
|
| `cabana.AdminRelationChildCreate` | Swag annotation of the relation child create route. |
|
||||||
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
|
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
|
||||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||||
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
||||||
@@ -196,7 +199,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
|||||||
| `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. |
|
| `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. |
|
||||||
| `app.url` | empty | Base URL used for the token issuer. |
|
| `app.url` | empty | Base URL used for the token issuer. |
|
||||||
| `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a larger `maxFilesize`. Without it the cap is the field's limit, or 128 MiB. |
|
| `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a larger `maxFilesize`. Without it the cap is the field's limit, or 128 MiB. |
|
||||||
| `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of the file caption and reorder routes (1 MiB when not set). |
|
| `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of the file caption and reorder routes and of the relation child routes (1 MiB when not set; 413 `payload_too_large` past it). |
|
||||||
|
|
||||||
The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.
|
The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.
|
||||||
|
|
||||||
|
|||||||
@@ -612,6 +612,29 @@ func AdminRelationLink() {}
|
|||||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
|
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
|
||||||
func AdminRelationUnlink() {}
|
func AdminRelationUnlink() {}
|
||||||
|
|
||||||
|
// AdminRelationChildCreate documents the relation child create route.
|
||||||
|
//
|
||||||
|
// @Summary 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.
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BackendBearer
|
||||||
|
// @Param vendor path string true "Vendor"
|
||||||
|
// @Param plugin path string true "Plugin"
|
||||||
|
// @Param controller path string true "Controller"
|
||||||
|
// @Param id path integer true "Owner id"
|
||||||
|
// @Param name path string true "Relation name"
|
||||||
|
// @Param body body AdminRecord true "Field values of the manage form keyed by field name"
|
||||||
|
// @Success 201 {object} RecordEnvelope
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 413 {object} ErrorEnvelope
|
||||||
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records [post]
|
||||||
|
func AdminRelationChildCreate() {}
|
||||||
|
|
||||||
// FileMutationResult is the payload of a file removal: the number of files
|
// FileMutationResult is the payload of a file removal: the number of files
|
||||||
// removed (always 1 on success).
|
// removed (always 1 on success).
|
||||||
type FileMutationResult struct {
|
type FileMutationResult struct {
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func datepickerFields(extra string) string {
|
|||||||
type: relation-manager
|
type: relation-manager
|
||||||
relation: members
|
relation: members
|
||||||
context: [update]
|
context: [update]
|
||||||
|
parts:
|
||||||
|
type: relation-manager
|
||||||
|
relation: parts
|
||||||
lookup:
|
lookup:
|
||||||
label: Lookup
|
label: Lookup
|
||||||
type: widget
|
type: widget
|
||||||
|
|||||||
26
modules/cabana/example_relation_test.go
Normal file
26
modules/cabana/example_relation_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import "git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
|
||||||
|
// Comment is one comment of a post: a hasMany child that points at its post
|
||||||
|
// through the nullable post_id column.
|
||||||
|
type Comment struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
PostID *uint `gorm:"column:post_id"`
|
||||||
|
Author string `gorm:"column:author"`
|
||||||
|
Body string `gorm:"column:body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Comment) TableName() string { return "acme_blog_comments" }
|
||||||
|
|
||||||
|
// 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"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -304,34 +304,47 @@ func decodeFields(raw []byte) ([]FormField, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
|
func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
|
||||||
|
items, err := decodeFieldMapping(node, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.items = items
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFieldMapping compiles a fields mapping in source order. rename, when
|
||||||
|
// set, maps a raw field name (such as WinterCMS's pivot[role]) to the name
|
||||||
|
// that must be an identifier.
|
||||||
|
func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField, error) {
|
||||||
node = unwrapNode(node)
|
node = unwrapNode(node)
|
||||||
if _, ok := node.(*ast.NullNode); ok || node == nil {
|
if _, ok := node.(*ast.NullNode); ok || node == nil {
|
||||||
m.items = []FormField{}
|
return []FormField{}, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
mapping, ok := node.(*ast.MappingNode)
|
mapping, ok := node.(*ast.MappingNode)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("fields must be a mapping")
|
return nil, fmt.Errorf("fields must be a mapping")
|
||||||
}
|
}
|
||||||
items := make([]FormField, 0, len(mapping.Values))
|
items := make([]FormField, 0, len(mapping.Values))
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, entry := range mapping.Values {
|
for _, entry := range mapping.Values {
|
||||||
name, err := nodeString(unwrapNode(entry.Key))
|
name, err := nodeString(unwrapNode(entry.Key))
|
||||||
|
if err == nil && rename != nil {
|
||||||
|
name = rename(name)
|
||||||
|
}
|
||||||
if err != nil || !identifier(name) {
|
if err != nil || !identifier(name) {
|
||||||
return fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key))
|
return nil, fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key))
|
||||||
}
|
}
|
||||||
if _, dup := seen[name]; dup {
|
if _, dup := seen[name]; dup {
|
||||||
return fmt.Errorf("duplicate field %s", name)
|
return nil, fmt.Errorf("duplicate field %s", name)
|
||||||
}
|
}
|
||||||
seen[name] = struct{}{}
|
seen[name] = struct{}{}
|
||||||
field, err := compileFieldNode(name, unwrapNode(entry.Value))
|
field, err := compileFieldNode(name, unwrapNode(entry.Value))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("field %s: %w", name, err)
|
return nil, fmt.Errorf("field %s: %w", name, err)
|
||||||
}
|
}
|
||||||
items = append(items, field)
|
items = append(items, field)
|
||||||
}
|
}
|
||||||
m.items = items
|
return items, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
||||||
|
|||||||
@@ -275,6 +275,10 @@ func (s *service) mount(r pact.Router) {
|
|||||||
constrainRelation(g)
|
constrainRelation(g)
|
||||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
|
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
|
||||||
constrainRelation(g)
|
constrainRelation(g)
|
||||||
|
// Relation child routes (D-11, D-12): create through the relation's
|
||||||
|
// manage form.
|
||||||
|
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", requireAjax(s.relationChildCreate))
|
||||||
|
constrainRelation(g)
|
||||||
// File routes of `type: fileupload` fields (D-09). {id} 0 is the
|
// File routes of `type: fileupload` fields (D-09). {id} 0 is the
|
||||||
// record being created in the X-Session-Key session.
|
// record being created in the X-Session-Key session.
|
||||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
|
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
|
||||||
@@ -577,7 +581,7 @@ func (s *service) relations() (RelationService, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RelationService{}, err
|
return RelationService{}, err
|
||||||
}
|
}
|
||||||
return RelationService{DB: db}, nil
|
return RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -81,6 +81,24 @@ type relationMessageKeys struct {
|
|||||||
UnlinkConfirm string `yaml:"unlinkConfirm"`
|
UnlinkConfirm string `yaml:"unlinkConfirm"`
|
||||||
Unlinked string `yaml:"unlinked"`
|
Unlinked string `yaml:"unlinked"`
|
||||||
Empty string `yaml:"empty"`
|
Empty string `yaml:"empty"`
|
||||||
|
// The child create, update and delete copy and the pivot copy (12.2).
|
||||||
|
Create string `yaml:"create"`
|
||||||
|
CreateTitle string `yaml:"createTitle"`
|
||||||
|
UpdateTitle string `yaml:"updateTitle"`
|
||||||
|
PreviewTitle string `yaml:"previewTitle"`
|
||||||
|
Created string `yaml:"created"`
|
||||||
|
Updated string `yaml:"updated"`
|
||||||
|
DeleteSelected string `yaml:"deleteSelected"`
|
||||||
|
DeleteConfirm string `yaml:"deleteConfirm"`
|
||||||
|
DeleteOneConfirm string `yaml:"deleteOneConfirm"`
|
||||||
|
Deleted string `yaml:"deleted"`
|
||||||
|
PivotTitle string `yaml:"pivotTitle"`
|
||||||
|
PivotSaved string `yaml:"pivotSaved"`
|
||||||
|
EditPivot string `yaml:"editPivot"`
|
||||||
|
CreateSubmit string `yaml:"createSubmit"`
|
||||||
|
UpdateSubmit string `yaml:"updateSubmit"`
|
||||||
|
PivotSubmit string `yaml:"pivotSubmit"`
|
||||||
|
LinkSubmit string `yaml:"linkSubmit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RelationMessages is a relation manager's copy, every key resolved.
|
// RelationMessages is a relation manager's copy, every key resolved.
|
||||||
@@ -93,6 +111,23 @@ type RelationMessages struct {
|
|||||||
UnlinkConfirm MessageForms `json:"unlinkConfirm"`
|
UnlinkConfirm MessageForms `json:"unlinkConfirm"`
|
||||||
Unlinked MessageForms `json:"unlinked"`
|
Unlinked MessageForms `json:"unlinked"`
|
||||||
Empty MessageForms `json:"empty"`
|
Empty MessageForms `json:"empty"`
|
||||||
|
Create MessageForms `json:"create"`
|
||||||
|
CreateTitle MessageForms `json:"createTitle"`
|
||||||
|
UpdateTitle MessageForms `json:"updateTitle"`
|
||||||
|
PreviewTitle MessageForms `json:"previewTitle"`
|
||||||
|
Created MessageForms `json:"created"`
|
||||||
|
Updated MessageForms `json:"updated"`
|
||||||
|
DeleteSelected MessageForms `json:"deleteSelected"`
|
||||||
|
DeleteConfirm MessageForms `json:"deleteConfirm"`
|
||||||
|
DeleteOneConfirm MessageForms `json:"deleteOneConfirm"`
|
||||||
|
Deleted MessageForms `json:"deleted"`
|
||||||
|
PivotTitle MessageForms `json:"pivotTitle"`
|
||||||
|
PivotSaved MessageForms `json:"pivotSaved"`
|
||||||
|
EditPivot MessageForms `json:"editPivot"`
|
||||||
|
CreateSubmit MessageForms `json:"createSubmit"`
|
||||||
|
UpdateSubmit MessageForms `json:"updateSubmit"`
|
||||||
|
PivotSubmit MessageForms `json:"pivotSubmit"`
|
||||||
|
LinkSubmit MessageForms `json:"linkSubmit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Framework defaults (D-13): every omitted key falls back to backend::lang.
|
// Framework defaults (D-13): every omitted key falls back to backend::lang.
|
||||||
@@ -125,6 +160,23 @@ var (
|
|||||||
UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm",
|
UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm",
|
||||||
Unlinked: "backend::lang.messages.relation.unlinked",
|
Unlinked: "backend::lang.messages.relation.unlinked",
|
||||||
Empty: "backend::lang.messages.relation.empty",
|
Empty: "backend::lang.messages.relation.empty",
|
||||||
|
Create: "backend::lang.messages.relation.create",
|
||||||
|
CreateTitle: "backend::lang.messages.relation.create_title",
|
||||||
|
UpdateTitle: "backend::lang.messages.relation.update_title",
|
||||||
|
PreviewTitle: "backend::lang.messages.relation.preview_title",
|
||||||
|
Created: "backend::lang.messages.relation.created",
|
||||||
|
Updated: "backend::lang.messages.relation.updated",
|
||||||
|
DeleteSelected: "backend::lang.messages.relation.delete_selected",
|
||||||
|
DeleteConfirm: "backend::lang.messages.relation.delete_confirm",
|
||||||
|
DeleteOneConfirm: "backend::lang.messages.relation.delete_one_confirm",
|
||||||
|
Deleted: "backend::lang.messages.relation.deleted",
|
||||||
|
PivotTitle: "backend::lang.messages.relation.pivot_title",
|
||||||
|
PivotSaved: "backend::lang.messages.relation.pivot_saved",
|
||||||
|
EditPivot: "backend::lang.messages.relation.edit_pivot",
|
||||||
|
CreateSubmit: "backend::lang.messages.relation.create_submit",
|
||||||
|
UpdateSubmit: "backend::lang.messages.relation.update_submit",
|
||||||
|
PivotSubmit: "backend::lang.messages.relation.pivot_submit",
|
||||||
|
LinkSubmit: "backend::lang.messages.relation.link_submit",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,9 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
|||||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
|
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
|
||||||
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
|
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
|
||||||
|
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
|
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", e.gadgetID), map[string]any{"label": "part-" + e.stamp}, true)
|
||||||
|
}, into[cabana.RecordEnvelope](), nil},
|
||||||
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
|
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
|
||||||
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
|
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
|
||||||
@@ -474,7 +477,7 @@ func dataID(t *testing.T, raw []byte) uint {
|
|||||||
func newConformEnv(t *testing.T) *conformEnv {
|
func newConformEnv(t *testing.T) *conformEnv {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
gdb := adminGorm(t)
|
gdb := adminGorm(t)
|
||||||
models := []any{&conformGadget{}, &conformGroup{}, &conformMember{}, &conformGadgetMember{}, &conformSettings{}}
|
models := []any{&conformGadget{}, &conformGroup{}, &conformMember{}, &conformGadgetMember{}, &conformPart{}, &conformSettings{}}
|
||||||
if err := gdb.Migrator().DropTable(models...); err != nil {
|
if err := gdb.Migrator().DropTable(models...); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -543,6 +546,7 @@ type conformGadget struct {
|
|||||||
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
|
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
|
||||||
StartsAt *time.Time `gorm:"column:starts_at"`
|
StartsAt *time.Time `gorm:"column:starts_at"`
|
||||||
Members []conformMember `gorm:"-"`
|
Members []conformMember `gorm:"-"`
|
||||||
|
Parts []conformPart `gorm:"-"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at"`
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
}
|
}
|
||||||
@@ -586,6 +590,20 @@ type conformGadgetMember struct {
|
|||||||
|
|
||||||
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
|
func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" }
|
||||||
|
|
||||||
|
// conformPart is the hasMany child of a gadget; its nullable gadget_id makes
|
||||||
|
// the parts relation deferrable.
|
||||||
|
type conformPart struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
GadgetID *uint `gorm:"column:gadget_id"`
|
||||||
|
Label string `gorm:"column:label"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conformPart) TableName() string { return "cabana_conform_parts" }
|
||||||
|
func (conformPart) Fillable() []string { return []string{"label"} }
|
||||||
|
func (conformPart) Rules() map[string]string { return map[string]string{"label": "required"} }
|
||||||
|
|
||||||
type conformSettings struct {
|
type conformSettings struct {
|
||||||
ID uint `gorm:"column:id;primaryKey"`
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
Enabled bool `gorm:"column:enabled"`
|
Enabled bool `gorm:"column:enabled"`
|
||||||
@@ -630,6 +648,9 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract {
|
|||||||
return []cabana.RelationContract{{
|
return []cabana.RelationContract{{
|
||||||
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
|
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
|
||||||
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
|
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
|
||||||
|
}, {
|
||||||
|
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
|
||||||
|
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
|
func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||||
@@ -748,6 +769,20 @@ update:
|
|||||||
email:
|
email:
|
||||||
label: Email
|
label: Email
|
||||||
showSearch: true
|
showSearch: true
|
||||||
|
parts:
|
||||||
|
label: Parts
|
||||||
|
view:
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
label:
|
||||||
|
label: Label
|
||||||
|
toolbarButtons: create
|
||||||
|
manage:
|
||||||
|
form: $/acme/conform/models/part/fields.yaml
|
||||||
|
list:
|
||||||
|
columns:
|
||||||
|
label:
|
||||||
|
label: Label
|
||||||
`),
|
`),
|
||||||
"models/gadget/columns.yaml": file(`columns:
|
"models/gadget/columns.yaml": file(`columns:
|
||||||
name:
|
name:
|
||||||
@@ -779,6 +814,9 @@ update:
|
|||||||
type: relation-manager
|
type: relation-manager
|
||||||
relation: members
|
relation: members
|
||||||
context: [update]
|
context: [update]
|
||||||
|
parts:
|
||||||
|
type: relation-manager
|
||||||
|
relation: parts
|
||||||
lookup:
|
lookup:
|
||||||
label: Lookup
|
label: Lookup
|
||||||
type: widget
|
type: widget
|
||||||
@@ -814,6 +852,12 @@ update:
|
|||||||
fileTypes: [pdf, png, svg, txt]
|
fileTypes: [pdf, png, svg, txt]
|
||||||
useCaption: true
|
useCaption: true
|
||||||
context: update
|
context: update
|
||||||
|
`),
|
||||||
|
"models/part/fields.yaml": file(`fields:
|
||||||
|
label:
|
||||||
|
label: Label
|
||||||
|
type: text
|
||||||
|
required: true
|
||||||
`),
|
`),
|
||||||
"models/settings/fields.yaml": file(`fields:
|
"models/settings/fields.yaml": file(`fields:
|
||||||
enabled:
|
enabled:
|
||||||
|
|||||||
@@ -100,13 +100,14 @@ func TestPhase10Coverage(t *testing.T) {
|
|||||||
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
|
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder",
|
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
||||||
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
||||||
"PUT /settings/{code}",
|
"PUT /settings/{code}",
|
||||||
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
||||||
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||||
}
|
}
|
||||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
||||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 15 besides login):\n%s", strings.Join(unsafe, "\n"))
|
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 16 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||||
}
|
}
|
||||||
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||||
// nested pattern serving field options, filter options and relation lists.
|
// nested pattern serving field options, filter options and relation lists.
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ func TestPhase10CSRF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// refresh, logout, settings put, create, bulk-delete, widget action,
|
// refresh, logout, settings put, create, bulk-delete, widget action,
|
||||||
// toolbar action, update, delete, link, unlink, file upload, file
|
// toolbar action, update, delete, link, unlink, file upload, file
|
||||||
// reorder, file caption, file remove
|
// reorder, file caption, file remove, relation child create
|
||||||
if unsafe != 15 {
|
if unsafe != 16 {
|
||||||
t.Fatalf("walked %d state-changing routes, want 15: %v", unsafe, router.order)
|
t.Fatalf("walked %d state-changing routes, want 16: %v", unsafe, router.order)
|
||||||
}
|
}
|
||||||
|
|
||||||
loginHandler := router.handlers[login]
|
loginHandler := router.handlers[login]
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
|
"gocloud.dev/blob"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -23,18 +25,46 @@ type AdminRelationContractProvider interface {
|
|||||||
AdminRelationContracts() []RelationContract
|
AdminRelationContracts() []RelationContract
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Relation contract kinds (RelationContract.Kind).
|
||||||
|
const (
|
||||||
|
// RelationBelongsToMany links related records through a pivot model.
|
||||||
|
// It is the kind of a contract whose Kind is empty.
|
||||||
|
RelationBelongsToMany = "belongsToMany"
|
||||||
|
// RelationHasMany owns related records through their ForeignKey column.
|
||||||
|
RelationHasMany = "hasMany"
|
||||||
|
)
|
||||||
|
|
||||||
// RelationContract binds one compiled relation schema to target and pivot models.
|
// RelationContract binds one compiled relation schema to target and pivot models.
|
||||||
type RelationContract struct {
|
type RelationContract struct {
|
||||||
Name string
|
Name string
|
||||||
|
// Kind is RelationBelongsToMany or RelationHasMany. The empty value is
|
||||||
|
// RelationBelongsToMany, so a contract written before hasMany existed
|
||||||
|
// keeps its pivot behaviour unchanged.
|
||||||
|
Kind string
|
||||||
NewRelated func() any
|
NewRelated func() any
|
||||||
|
// NewPivot, ParentForeignKey, RelatedForeignKey and HookPivotColumns
|
||||||
|
// describe the pivot of a belongsToMany relation; a hasMany contract
|
||||||
|
// leaves them empty.
|
||||||
NewPivot func() any
|
NewPivot func() any
|
||||||
ParentForeignKey string
|
ParentForeignKey string
|
||||||
RelatedForeignKey string
|
RelatedForeignKey string
|
||||||
|
// ForeignKey is the related model's column that points at the parent
|
||||||
|
// (hasMany only). A pointer Go type (a nullable column) is needed for
|
||||||
|
// unlink and for managing the relation before the parent is saved.
|
||||||
|
ForeignKey string
|
||||||
Columns map[string]string
|
Columns map[string]string
|
||||||
HookPivotColumns []string
|
HookPivotColumns []string
|
||||||
ExcludedRelatedIDs func(parent any) ([]uint, error)
|
ExcludedRelatedIDs func(parent any) ([]uint, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kind is the contract's normalized kind.
|
||||||
|
func (c RelationContract) kind() string {
|
||||||
|
if c.Kind == "" {
|
||||||
|
return RelationBelongsToMany
|
||||||
|
}
|
||||||
|
return c.Kind
|
||||||
|
}
|
||||||
|
|
||||||
// RelationColumn is one source-ordered relation list column.
|
// RelationColumn is one source-ordered relation list column.
|
||||||
type RelationColumn struct {
|
type RelationColumn struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
@@ -59,13 +89,33 @@ type RelationPanel struct {
|
|||||||
type RelationSchema struct {
|
type RelationSchema struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
// Kind is the contract kind: belongsToMany or hasMany.
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
// 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 bool `json:"deferrable"`
|
||||||
View RelationPanel `json:"view"`
|
View RelationPanel `json:"view"`
|
||||||
Manage RelationPanel `json:"manage"`
|
Manage RelationPanel `json:"manage"`
|
||||||
|
// 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 []FormField `json:"manageForm,omitempty"`
|
||||||
|
ViewForm []FormField `json:"viewForm,omitempty"`
|
||||||
|
PivotForm []FormField `json:"pivotForm,omitempty"`
|
||||||
// Messages is the relation manager's copy (D-13); the cached schema
|
// Messages is the relation manager's copy (D-13); the cached schema
|
||||||
// carries each phrase key as its own form.
|
// carries each phrase key as its own form.
|
||||||
Messages *RelationMessages `json:"messages"`
|
Messages *RelationMessages `json:"messages"`
|
||||||
|
|
||||||
messageKeys relationMessageKeys
|
messageKeys relationMessageKeys
|
||||||
|
// manageForm, viewForm and pivotForm are the compiled forms the field
|
||||||
|
// lists come from; relatedModel and pivotModel supply dropdown options.
|
||||||
|
manageForm *FormSchema
|
||||||
|
viewForm *FormSchema
|
||||||
|
pivotForm *FormSchema
|
||||||
|
relatedModel func() any
|
||||||
|
pivotModel func() any
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompiledRelation combines trusted YAML with model-owned metadata.
|
// CompiledRelation combines trusted YAML with model-owned metadata.
|
||||||
@@ -73,6 +123,27 @@ type CompiledRelation struct {
|
|||||||
Schema *RelationSchema
|
Schema *RelationSchema
|
||||||
Contract RelationContract
|
Contract RelationContract
|
||||||
RequiredPermissions []string
|
RequiredPermissions []string
|
||||||
|
|
||||||
|
// kind is the normalized contract kind; deferrable mirrors
|
||||||
|
// Schema.Deferrable; fieldName is the relation-manager form field.
|
||||||
|
kind string
|
||||||
|
deferrable bool
|
||||||
|
fieldName string
|
||||||
|
// child is the related model's form (the manage form) compiled as a
|
||||||
|
// controller of its own: writable fields, file and date fields. view is
|
||||||
|
// the read-only form when it differs. pivot is the pivot form bound to
|
||||||
|
// the pivot model. Each is nil when not declared.
|
||||||
|
child *CompiledController
|
||||||
|
view *CompiledController
|
||||||
|
pivot *CompiledController
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasMany reports whether the relation owns its children by foreign key.
|
||||||
|
func (cr *CompiledRelation) hasMany() bool { return cr != nil && cr.kind == RelationHasMany }
|
||||||
|
|
||||||
|
// allows reports whether the view panel declares the toolbar button.
|
||||||
|
func (cr *CompiledRelation) allows(button string) bool {
|
||||||
|
return cr != nil && cr.Schema != nil && slices.Contains(cr.Schema.View.ToolbarButtons, button)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RelationQuery is the finite linked/candidate query contract.
|
// RelationQuery is the finite linked/candidate query contract.
|
||||||
@@ -102,7 +173,14 @@ type RelationMutationResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RelationService executes compiled relation reads and writes.
|
// RelationService executes compiled relation reads and writes.
|
||||||
type RelationService struct{ DB *gorm.DB }
|
type RelationService struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
|
||||||
|
// bucket deletes the blobs of child files a save removes, after commit;
|
||||||
|
// tr localizes date bound messages. Both may be nil.
|
||||||
|
bucket *blob.Bucket
|
||||||
|
tr *phrasebook.Translator
|
||||||
|
}
|
||||||
|
|
||||||
func (s RelationSchema) MarshalJSON() ([]byte, error) {
|
func (s RelationSchema) MarshalJSON() ([]byte, error) {
|
||||||
if s.Messages == nil {
|
if s.Messages == nil {
|
||||||
@@ -136,9 +214,30 @@ func (s *RelationSchema) Localize(ctx context.Context, tr *phrasebook.Translator
|
|||||||
out.Manage = localizeRelationPanel(ctx, tr, s.Manage)
|
out.Manage = localizeRelationPanel(ctx, tr, s.Manage)
|
||||||
messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet())
|
messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet())
|
||||||
out.Messages = &messages
|
out.Messages = &messages
|
||||||
|
out.ManageForm = localizeRelationForm(ctx, tr, s.manageForm, s.relatedModel, s.ManageForm)
|
||||||
|
out.ViewForm = localizeRelationForm(ctx, tr, s.viewForm, s.relatedModel, s.ViewForm)
|
||||||
|
out.PivotForm = localizeRelationForm(ctx, tr, s.pivotForm, s.pivotModel, s.PivotForm)
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localizeRelationForm resolves a relation form's display strings, with the
|
||||||
|
// form's model as the dropdown options provider. A form that cannot be
|
||||||
|
// localized keeps its cached fields.
|
||||||
|
func localizeRelationForm(ctx context.Context, tr *phrasebook.Translator, form *FormSchema, model func() any, cached []FormField) []FormField {
|
||||||
|
if form == nil {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
var provider pact.DropdownOptionsProvider
|
||||||
|
if model != nil {
|
||||||
|
provider = modelDropdownProvider(model())
|
||||||
|
}
|
||||||
|
view, err := form.Localize(ctx, tr, provider)
|
||||||
|
if err != nil {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
return view.Fields
|
||||||
|
}
|
||||||
|
|
||||||
func localizeRelationPanel(ctx context.Context, tr *phrasebook.Translator, src RelationPanel) RelationPanel {
|
func localizeRelationPanel(ctx context.Context, tr *phrasebook.Translator, src RelationPanel) RelationPanel {
|
||||||
out := src
|
out := src
|
||||||
out.List.Columns = append([]RelationColumn(nil), src.List.Columns...)
|
out.List.Columns = append([]RelationColumn(nil), src.List.Columns...)
|
||||||
@@ -156,8 +255,12 @@ type relationRoot struct {
|
|||||||
|
|
||||||
type relationDocument struct {
|
type relationDocument struct {
|
||||||
Label string `yaml:"label"`
|
Label string `yaml:"label"`
|
||||||
|
// Form is WinterCMS's top-level form: the fallback of manage.form and
|
||||||
|
// view.form.
|
||||||
|
Form string `yaml:"form"`
|
||||||
View relationPanelDocument `yaml:"view"`
|
View relationPanelDocument `yaml:"view"`
|
||||||
Manage relationPanelDocument `yaml:"manage"`
|
Manage relationPanelDocument `yaml:"manage"`
|
||||||
|
Pivot *relationPivotDocument `yaml:"pivot"`
|
||||||
Messages *relationMessageKeys `yaml:"messages"`
|
Messages *relationMessageKeys `yaml:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +268,15 @@ type relationPanelDocument struct {
|
|||||||
List struct {
|
List struct {
|
||||||
Columns yaml.MapSlice `yaml:"columns"`
|
Columns yaml.MapSlice `yaml:"columns"`
|
||||||
} `yaml:"list"`
|
} `yaml:"list"`
|
||||||
|
Form string `yaml:"form"`
|
||||||
ToolbarButtons string `yaml:"toolbarButtons"`
|
ToolbarButtons string `yaml:"toolbarButtons"`
|
||||||
ShowSearch bool `yaml:"showSearch"`
|
ShowSearch bool `yaml:"showSearch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type relationPivotDocument struct {
|
||||||
|
Form string `yaml:"form"`
|
||||||
|
}
|
||||||
|
|
||||||
type relationColumnDocument struct {
|
type relationColumnDocument struct {
|
||||||
Label string `yaml:"label"`
|
Label string `yaml:"label"`
|
||||||
Searchable *bool `yaml:"searchable"`
|
Searchable *bool `yaml:"searchable"`
|
||||||
@@ -176,11 +284,12 @@ type relationColumnDocument struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema) (map[string]*CompiledRelation, error) {
|
func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema) (map[string]*CompiledRelation, error) {
|
||||||
fields := map[string]FormField{}
|
// fields maps each relation-manager relation to its form field index.
|
||||||
|
fields := map[string]int{}
|
||||||
if form != nil {
|
if form != nil {
|
||||||
for _, field := range form.Fields {
|
for i, field := range form.Fields {
|
||||||
if field.Type == "relation-manager" {
|
if field.Type == "relation-manager" {
|
||||||
fields[field.Relation] = field
|
fields[field.Relation] = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,17 +355,26 @@ func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, for
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s manage: %w", name, err))
|
return nil, bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s manage: %w", name, err))
|
||||||
}
|
}
|
||||||
schema := &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage}
|
schema := &RelationSchema{Name: name, Label: doc.Label, Kind: contract.kind(), View: view, Manage: manage}
|
||||||
if doc.Messages != nil {
|
if doc.Messages != nil {
|
||||||
schema.messageKeys = *doc.Messages
|
schema.messageKeys = *doc.Messages
|
||||||
}
|
}
|
||||||
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet())
|
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet())
|
||||||
schema.Messages = &keys
|
schema.Messages = &keys
|
||||||
out[name] = &CompiledRelation{
|
cr := &CompiledRelation{
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Contract: contract,
|
Contract: contract,
|
||||||
RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
|
RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
|
||||||
|
kind: contract.kind(),
|
||||||
|
deferrable: relationDeferrable(contract),
|
||||||
|
fieldName: form.Fields[fields[name]].Name,
|
||||||
}
|
}
|
||||||
|
schema.Deferrable = cr.deferrable
|
||||||
|
if err := compileRelationForms(pluginID, ctl, fsys, file, doc, cr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
form.Fields[fields[name]].Deferrable = cr.deferrable
|
||||||
|
out[name] = cr
|
||||||
}
|
}
|
||||||
for name := range fields {
|
for name := range fields {
|
||||||
if out[name] == nil {
|
if out[name] == nil {
|
||||||
@@ -311,6 +429,11 @@ func compileRelationPanel(doc relationPanelDocument, contract RelationContract,
|
|||||||
return RelationPanel{List: RelationList{Columns: cols}, ToolbarButtons: buttons, ShowSearch: doc.ShowSearch}, nil
|
return RelationPanel{List: RelationList{Columns: cols}, ToolbarButtons: buttons, ShowSearch: doc.ShowSearch}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// relationButtons are the WinterCMS RelationController toolbar buttons the
|
||||||
|
// view panel may declare (D-12). Each one is the capability of its routes:
|
||||||
|
// create, update (row edit), delete, link and unlink.
|
||||||
|
var relationButtons = []string{"create", "update", "delete", "link", "unlink"}
|
||||||
|
|
||||||
func compileRelationButtons(raw string, view bool) ([]string, error) {
|
func compileRelationButtons(raw string, view bool) ([]string, error) {
|
||||||
if strings.TrimSpace(raw) == "" {
|
if strings.TrimSpace(raw) == "" {
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
@@ -320,11 +443,11 @@ func compileRelationButtons(raw string, view bool) ([]string, error) {
|
|||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
part = strings.TrimSpace(part)
|
part = strings.TrimSpace(part)
|
||||||
if part != "link" && part != "unlink" {
|
if !slices.Contains(relationButtons, part) {
|
||||||
return nil, fmt.Errorf("unsupported relation action %s", part)
|
return nil, fmt.Errorf("unsupported relation action %s", part)
|
||||||
}
|
}
|
||||||
if !view && part == "unlink" {
|
if !view && part != "link" {
|
||||||
return nil, fmt.Errorf("manage panel cannot declare unlink")
|
return nil, fmt.Errorf("manage panel cannot declare %s (only link)", part)
|
||||||
}
|
}
|
||||||
if _, dup := seen[part]; dup {
|
if _, dup := seen[part]; dup {
|
||||||
return nil, fmt.Errorf("duplicate relation action %s", part)
|
return nil, fmt.Errorf("duplicate relation action %s", part)
|
||||||
@@ -336,18 +459,17 @@ func compileRelationButtons(raw string, view bool) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateRelationContract(ctl pact.AdminController, contract RelationContract) error {
|
func validateRelationContract(ctl pact.AdminController, contract RelationContract) error {
|
||||||
if contract.NewRelated == nil || contract.NewRelated() == nil || contract.NewPivot == nil || contract.NewPivot() == nil {
|
switch contract.kind() {
|
||||||
return fmt.Errorf("relation %s requires target and pivot models", contract.Name)
|
case RelationBelongsToMany:
|
||||||
|
if err := validatePivotContract(contract); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if !identifier(contract.ParentForeignKey) || !identifier(contract.RelatedForeignKey) {
|
case RelationHasMany:
|
||||||
return fmt.Errorf("relation %s has invalid pivot foreign keys", contract.Name)
|
if err := validateHasManyContract(contract); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
pivotCols := modelColumns(contract.NewPivot())
|
default:
|
||||||
if _, ok := pivotCols[contract.ParentForeignKey]; !ok {
|
return fmt.Errorf("relation %s has unknown kind %q (want %s or %s)", contract.Name, contract.Kind, RelationBelongsToMany, RelationHasMany)
|
||||||
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.ParentForeignKey)
|
|
||||||
}
|
|
||||||
if _, ok := pivotCols[contract.RelatedForeignKey]; !ok {
|
|
||||||
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.RelatedForeignKey)
|
|
||||||
}
|
}
|
||||||
targetCols := modelColumns(contract.NewRelated())
|
targetCols := modelColumns(contract.NewRelated())
|
||||||
for logical, physical := range contract.Columns {
|
for logical, physical := range contract.Columns {
|
||||||
@@ -358,14 +480,6 @@ func validateRelationContract(ctl pact.AdminController, contract RelationContrac
|
|||||||
return fmt.Errorf("relation %s target is missing column %s", contract.Name, physical)
|
return fmt.Errorf("relation %s target is missing column %s", contract.Name, physical)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, col := range contract.HookPivotColumns {
|
|
||||||
if !identifier(col) {
|
|
||||||
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
|
|
||||||
}
|
|
||||||
if _, ok := pivotCols[col]; !ok || protectedPivotColumn(col, contract) {
|
|
||||||
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
src, ok := ctl.(pact.AdminRecordSource)
|
src, ok := ctl.(pact.AdminRecordSource)
|
||||||
if !ok || src == nil || src.NewRecord() == nil {
|
if !ok || src == nil || src.NewRecord() == nil {
|
||||||
return fmt.Errorf("relation %s owner has no record source", contract.Name)
|
return fmt.Errorf("relation %s owner has no record source", contract.Name)
|
||||||
@@ -393,6 +507,71 @@ func validateRelationContract(ctl pact.AdminController, contract RelationContrac
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePivotContract checks a belongsToMany contract: target and pivot
|
||||||
|
// models, both pivot foreign keys on the pivot model and the hook columns.
|
||||||
|
func validatePivotContract(contract RelationContract) error {
|
||||||
|
if contract.NewRelated == nil || contract.NewRelated() == nil || contract.NewPivot == nil || contract.NewPivot() == nil {
|
||||||
|
return fmt.Errorf("relation %s requires target and pivot models", contract.Name)
|
||||||
|
}
|
||||||
|
if contract.ForeignKey != "" {
|
||||||
|
return fmt.Errorf("relation %s: a belongsToMany contract cannot declare ForeignKey", contract.Name)
|
||||||
|
}
|
||||||
|
if !identifier(contract.ParentForeignKey) || !identifier(contract.RelatedForeignKey) {
|
||||||
|
return fmt.Errorf("relation %s has invalid pivot foreign keys", contract.Name)
|
||||||
|
}
|
||||||
|
pivotCols := modelColumns(contract.NewPivot())
|
||||||
|
if _, ok := pivotCols[contract.ParentForeignKey]; !ok {
|
||||||
|
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.ParentForeignKey)
|
||||||
|
}
|
||||||
|
if _, ok := pivotCols[contract.RelatedForeignKey]; !ok {
|
||||||
|
return fmt.Errorf("relation %s pivot is missing %s", contract.Name, contract.RelatedForeignKey)
|
||||||
|
}
|
||||||
|
for _, col := range contract.HookPivotColumns {
|
||||||
|
if !identifier(col) {
|
||||||
|
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
|
||||||
|
}
|
||||||
|
if _, ok := pivotCols[col]; !ok || protectedPivotColumn(col, contract) {
|
||||||
|
return fmt.Errorf("relation %s has invalid hook column %s", contract.Name, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateHasManyContract checks a hasMany contract: no pivot fields, and a
|
||||||
|
// ForeignKey that is an integer column (or a pointer to one) of the related
|
||||||
|
// model.
|
||||||
|
func validateHasManyContract(contract RelationContract) error {
|
||||||
|
if contract.NewRelated == nil || contract.NewRelated() == nil {
|
||||||
|
return fmt.Errorf("relation %s requires a target model", contract.Name)
|
||||||
|
}
|
||||||
|
if contract.NewPivot != nil || contract.ParentForeignKey != "" || contract.RelatedForeignKey != "" || len(contract.HookPivotColumns) > 0 {
|
||||||
|
return fmt.Errorf("relation %s: a hasMany contract cannot declare NewPivot, ParentForeignKey, RelatedForeignKey or HookPivotColumns", contract.Name)
|
||||||
|
}
|
||||||
|
if !identifier(contract.ForeignKey) {
|
||||||
|
return fmt.Errorf("relation %s: hasMany needs a ForeignKey column", contract.Name)
|
||||||
|
}
|
||||||
|
field, ok := structFieldByColumn(contract.NewRelated(), contract.ForeignKey)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("relation %s target is missing column %s", contract.Name, contract.ForeignKey)
|
||||||
|
}
|
||||||
|
if !uintLike(field.Type) {
|
||||||
|
return fmt.Errorf("relation %s: ForeignKey %s must be an integer column, found %s", contract.Name, contract.ForeignKey, field.Type)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// relationDeferrable reports whether a relation can be managed before its
|
||||||
|
// parent is saved: a belongsToMany always (the pivot row is written on
|
||||||
|
// save), a hasMany only with a nullable ForeignKey (the child is inserted
|
||||||
|
// with a NULL key first, as in WinterCMS).
|
||||||
|
func relationDeferrable(contract RelationContract) bool {
|
||||||
|
if contract.kind() != RelationHasMany {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
field, ok := structFieldByColumn(contract.NewRelated(), contract.ForeignKey)
|
||||||
|
return ok && field.Type.Kind() == reflect.Pointer
|
||||||
|
}
|
||||||
|
|
||||||
func protectedPivotColumn(col string, contract RelationContract) bool {
|
func protectedPivotColumn(col string, contract RelationContract) bool {
|
||||||
switch col {
|
switch col {
|
||||||
case contract.ParentForeignKey, contract.RelatedForeignKey, "id", "created_at", "updated_at", "deleted_at":
|
case contract.ParentForeignKey, contract.RelatedForeignKey, "id", "created_at", "updated_at", "deleted_at":
|
||||||
@@ -463,6 +642,9 @@ func (s RelationService) query(ctx context.Context, cc *CompiledController, rela
|
|||||||
}
|
}
|
||||||
|
|
||||||
func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, candidates bool) (*gorm.DB, any, error) {
|
func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, candidates bool) (*gorm.DB, any, error) {
|
||||||
|
if cr.hasMany() {
|
||||||
|
return hasManyBaseQuery(ctx, tx, cc, cr, parent, candidates)
|
||||||
|
}
|
||||||
target := cr.Contract.NewRelated()
|
target := cr.Contract.NewRelated()
|
||||||
targetTable := tableName(target)
|
targetTable := tableName(target)
|
||||||
pivotTable := tableName(cr.Contract.NewPivot())
|
pivotTable := tableName(cr.Contract.NewPivot())
|
||||||
@@ -493,6 +675,33 @@ func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController,
|
|||||||
return q, target, nil
|
return q, target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasManyBaseQuery is relationBaseQuery for a hasMany relation: the linked
|
||||||
|
// rows are the related rows whose ForeignKey is the parent's key.
|
||||||
|
func hasManyBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, candidates bool) (*gorm.DB, any, error) {
|
||||||
|
target := cr.Contract.NewRelated()
|
||||||
|
fk := clause.Column{Table: tableName(target), Name: cr.Contract.ForeignKey}
|
||||||
|
q := tx.WithContext(ctx).Model(target)
|
||||||
|
if !candidates {
|
||||||
|
return q.Where(clause.Eq{Column: fk, Value: pkUint(parent)}), target, nil
|
||||||
|
}
|
||||||
|
// Candidates are rows no parent owns yet: a NULL ForeignKey.
|
||||||
|
if ext, ok := cc.Controller.(pact.RelationExtendManageQuery); ok && ext != nil {
|
||||||
|
if next := ext.RelationExtendManageQuery(ctx, cr.Contract.Name, q); next != nil {
|
||||||
|
q = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cr.Contract.ExcludedRelatedIDs != nil {
|
||||||
|
excluded, err := cr.Contract.ExcludedRelatedIDs(parent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
if len(excluded) > 0 {
|
||||||
|
q = q.Where(clause.Not(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(excluded)}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q.Where(clause.Eq{Column: fk, Value: nil}), target, nil
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeRelationPage applies the Phase 9 relation paging limits: page is
|
// normalizeRelationPage applies the Phase 9 relation paging limits: page is
|
||||||
// a positive integer (default 1), per_page is 1..100 (default 20).
|
// a positive integer (default 1), per_page is 1..100 (default 20).
|
||||||
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
|
func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) {
|
||||||
@@ -631,6 +840,9 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RelationMutationResult{}, err
|
return RelationMutationResult{}, err
|
||||||
}
|
}
|
||||||
|
if cr.hasMany() {
|
||||||
|
return RelationMutationResult{}, recordNotFound{}
|
||||||
|
}
|
||||||
var result RelationMutationResult
|
var result RelationMutationResult
|
||||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
ctx = withTx(ctx, tx)
|
ctx = withTx(ctx, tx)
|
||||||
@@ -660,8 +872,25 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
|
|||||||
}
|
}
|
||||||
for i := 0; i < holder.Elem().Len(); i++ {
|
for i := 0; i < holder.Elem().Len(); i++ {
|
||||||
related := holder.Elem().Index(i).Addr().Interface()
|
related := holder.Elem().Index(i).Addr().Interface()
|
||||||
pivot := cr.Contract.NewPivot()
|
if err := insertPivot(ctx, tx, cc, cr, parent, related, nil); err != nil {
|
||||||
if err := setModelColumn(pivot, cr.Contract.ParentForeignKey, ownerPK); err != nil {
|
return err
|
||||||
|
}
|
||||||
|
result.Linked++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertPivot writes the pivot row linking related to the saved parent of a
|
||||||
|
// belongsToMany relation. A filled pivot model (from the pivot form) may be
|
||||||
|
// passed; RelationBeforeLink then stamps only its HookPivotColumns, and the
|
||||||
|
// two foreign keys are always set here.
|
||||||
|
func insertPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent, related, pivot any) error {
|
||||||
|
if pivot == nil {
|
||||||
|
pivot = cr.Contract.NewPivot()
|
||||||
|
}
|
||||||
|
if err := setModelColumn(pivot, cr.Contract.ParentForeignKey, pkUint(parent)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil {
|
if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil {
|
||||||
@@ -669,7 +898,7 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
|
|||||||
}
|
}
|
||||||
values := map[string]any{}
|
values := map[string]any{}
|
||||||
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
|
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
|
||||||
if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil {
|
if err := hook.RelationBeforeLink(ctx, cr.Contract.Name, parent, related, values); err != nil {
|
||||||
return lifecycleFailure(cc, err)
|
return lifecycleFailure(cc, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -681,14 +910,7 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
|
|||||||
return lifecycleFailure(cc, err)
|
return lifecycleFailure(cc, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.WithContext(ctx).Create(pivot).Error; err != nil {
|
return tx.WithContext(ctx).Create(pivot).Error
|
||||||
return err
|
|
||||||
}
|
|
||||||
result.Linked++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return result, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlink deletes explicit pivot models so their lifecycle hooks run.
|
// Unlink deletes explicit pivot models so their lifecycle hooks run.
|
||||||
@@ -701,6 +923,9 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RelationMutationResult{}, err
|
return RelationMutationResult{}, err
|
||||||
}
|
}
|
||||||
|
if cr.hasMany() {
|
||||||
|
return RelationMutationResult{}, recordNotFound{}
|
||||||
|
}
|
||||||
var result RelationMutationResult
|
var result RelationMutationResult
|
||||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
ctx = withTx(ctx, tx)
|
ctx = withTx(ctx, tx)
|
||||||
|
|||||||
223
modules/cabana/relation_child.go
Normal file
223
modules/cabana/relation_child.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// relationParent is the parent record of a relation route, loaded through
|
||||||
|
// FormExtendQuery.
|
||||||
|
type relationParent struct {
|
||||||
|
model any
|
||||||
|
id uint
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadParent loads the parent record of a relation route through
|
||||||
|
// loadRecord (FormExtendQuery, FOR UPDATE). A missing or hidden parent, and
|
||||||
|
// id 0, are recordNotFound.
|
||||||
|
func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, ownerID uint) (*relationParent, error) {
|
||||||
|
parent, err := newWritableModel(cc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ownerID == 0 {
|
||||||
|
return nil, recordNotFound{}
|
||||||
|
}
|
||||||
|
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &relationParent{model: parent, id: ownerID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillChild fills and validates a child of a relation form like the
|
||||||
|
// controller save does: the body is projected through the form's writable
|
||||||
|
// fields for op, filled with lagoon.Fill, checked by BeforeValidate, the
|
||||||
|
// model rules merged with the form's required flags, and the datepicker
|
||||||
|
// bounds. A failure is a 422 ValidationError.
|
||||||
|
func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *CompiledController, model any, body map[string]any, op string) error {
|
||||||
|
projected := projectOperation(form, body, op)
|
||||||
|
if err := lagoon.Fill(model, fillAllowed(form, model, op), projected, false); err != nil {
|
||||||
|
var typed *lagoon.FillTypeError
|
||||||
|
if errors.As(err, &typed) {
|
||||||
|
return &ValidationError{Details: fillTypeDetails(typed.Key)}
|
||||||
|
}
|
||||||
|
return &CapabilityError{ControllerID: controllerID(form)}
|
||||||
|
}
|
||||||
|
if hook, ok := model.(lagoon.HasBeforeValidate); ok && hook != nil {
|
||||||
|
if err := hook.BeforeValidate(tx); err != nil {
|
||||||
|
return &CapabilityError{ControllerID: controllerID(form)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules := mergedRules(form, model, op)
|
||||||
|
msgs, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
|
||||||
|
if err != nil {
|
||||||
|
return &CapabilityError{ControllerID: controllerID(form)}
|
||||||
|
}
|
||||||
|
for field, extra := range dateBoundDetails(ctx, s.tr, form, model, op) {
|
||||||
|
if msgs == nil {
|
||||||
|
msgs = map[string][]string{}
|
||||||
|
}
|
||||||
|
msgs[field] = append(msgs[field], extra...)
|
||||||
|
}
|
||||||
|
if len(msgs) > 0 {
|
||||||
|
return &ValidationError{Details: validationDetails(msgs)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateChild creates a related record through the relation's manage form
|
||||||
|
// (D-11, D-16) and attaches it to the parent: a hasMany child gets the
|
||||||
|
// parent's key in its ForeignKey (set by the server, never from the body),
|
||||||
|
// a belongsToMany record gets a pivot row (RelationBeforeLink stamps its
|
||||||
|
// hook columns). The parent is loaded through FormExtendQuery. The child is
|
||||||
|
// filled and validated like a controller save, and the controller's
|
||||||
|
// optional pact.RelationBeforeCreate and pact.RelationAfterCreate hooks run
|
||||||
|
// around the insert; any failure rolls the whole create back.
|
||||||
|
func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RecordInput) (RecordResult, error) {
|
||||||
|
if s.DB == nil {
|
||||||
|
return RecordResult{}, errors.New("cabana: database is not configured")
|
||||||
|
}
|
||||||
|
cr, err := relationOf(cc, relation)
|
||||||
|
if err != nil {
|
||||||
|
return RecordResult{}, err
|
||||||
|
}
|
||||||
|
if cr.child == nil {
|
||||||
|
return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)}
|
||||||
|
}
|
||||||
|
var result RecordResult
|
||||||
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
ctx = withTx(ctx, tx)
|
||||||
|
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
child, err := newWritableModel(cr.child)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cr.hasMany() {
|
||||||
|
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "create"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hook, ok := cc.Controller.(pact.RelationBeforeCreate); ok && hook != nil {
|
||||||
|
if err := hook.RelationBeforeCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Create(child).Error; err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
if !cr.hasMany() {
|
||||||
|
if err := insertPivot(ctx, tx, cc, cr, parent.model, child, nil); err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hook, ok := cc.Controller.(pact.RelationAfterCreate); ok && hook != nil {
|
||||||
|
if err := hook.RelationAfterCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err = projectFullRecord(ctx, tx, cr.child, child)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RecordResult{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// relationButton resolves the route's relation and refuses (403) a route
|
||||||
|
// whose toolbar button the view panel does not declare. An unknown relation
|
||||||
|
// is 404. It writes the response and returns nil on refusal.
|
||||||
|
func (s *service) relationButton(w http.ResponseWriter, r *http.Request, cc *CompiledController, button string) *CompiledRelation {
|
||||||
|
cr, err := relationOf(cc, r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !cr.allows(button) {
|
||||||
|
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
||||||
|
s.logAuth(r, "denied", principal.ID)
|
||||||
|
}
|
||||||
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cr
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCappedObject decodes a JSON object body capped at
|
||||||
|
// http.body_limits.default_bytes; trailing data is refused.
|
||||||
|
func (s *service) decodeCappedObject(w http.ResponseWriter, r *http.Request) (map[string]any, error) {
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap()))
|
||||||
|
dec.UseNumber()
|
||||||
|
var body map[string]any
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
var tooBig *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooBig) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, invalidBody()
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := dec.Decode(&trailing); err != io.EOF {
|
||||||
|
return nil, invalidBody()
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
body = map[string]any{}
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRelationError maps a relation child route failure: a body past the
|
||||||
|
// cap is 413 payload_too_large, everything else as writeCRUDError.
|
||||||
|
func writeRelationError(w http.ResponseWriter, err error) {
|
||||||
|
var tooBig *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooBig) {
|
||||||
|
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// relationChildCreate serves POST .../{id}/relations/{name}/records.
|
||||||
|
func (s *service) relationChildCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
|
cr := s.relationButton(w, r, cc, "create")
|
||||||
|
if cr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := s.decodeCappedObject(w, r)
|
||||||
|
if err != nil {
|
||||||
|
writeRelationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := s.relations()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, err := svc.CreateChild(r.Context(), cc, cr.Contract.Name, id, RecordInput{Body: body})
|
||||||
|
if err != nil {
|
||||||
|
writeRelationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusCreated, rec.Data, rec.Meta)
|
||||||
|
})
|
||||||
|
}
|
||||||
80
modules/cabana/relation_child_smoke_test.go
Normal file
80
modules/cabana/relation_child_smoke_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// linkedIDs lists the ids of a gadget relation's linked rows.
|
||||||
|
func (e *conformEnv) linkedIDs(t *testing.T, gadget uint, relation string, headers map[string]string) []uint {
|
||||||
|
t.Helper()
|
||||||
|
rec := e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/%s", gadget, relation), nil, "", headers)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("linked %s status=%d body=%s", relation, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data []struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := make([]uint, 0, len(body.Data))
|
||||||
|
for _, row := range body.Data {
|
||||||
|
out = append(out, row.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// partOwner reads a part's gadget_id straight from the table.
|
||||||
|
func (e *conformEnv) partOwner(t *testing.T, id uint) *uint {
|
||||||
|
t.Helper()
|
||||||
|
var part conformPart
|
||||||
|
if err := e.db.First(&part, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return part.GadgetID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRelationChildSmokeCreate creates a hasMany child of a saved gadget
|
||||||
|
// through the relation manager: the server sets the foreign key from the
|
||||||
|
// scoped parent, the child lists under that parent only, and a body naming
|
||||||
|
// the foreign key cannot move it.
|
||||||
|
func TestRelationChildSmokeCreate(t *testing.T) {
|
||||||
|
env := newConformEnv(t)
|
||||||
|
env.loginAs(t, env.login)
|
||||||
|
a := env.createGadget(t, "a-"+env.stamp, "")
|
||||||
|
b := env.createGadget(t, "b-"+env.stamp, "")
|
||||||
|
|
||||||
|
rec := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", a), map[string]any{"label": "wheel", "gadget_id": b}, true)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create part status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
part := dataID(t, rec.Body.Bytes())
|
||||||
|
if owner := env.partOwner(t, part); owner == nil || *owner != a {
|
||||||
|
t.Fatalf("part gadget_id = %v, want %d", owner, a)
|
||||||
|
}
|
||||||
|
if got := env.linkedIDs(t, a, "parts", nil); !slices.Contains(got, part) {
|
||||||
|
t.Fatalf("gadget A parts = %v, want %d", got, part)
|
||||||
|
}
|
||||||
|
if got := env.linkedIDs(t, b, "parts", nil); slices.Contains(got, part) {
|
||||||
|
t.Fatalf("gadget B lists A's part: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", a), map[string]any{"label": ""}, true)
|
||||||
|
if invalid.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("empty label status=%d body=%s", invalid.Code, invalid.Body.String())
|
||||||
|
}
|
||||||
|
undeclared := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/records", a), map[string]any{"email": "x@example.test"}, true)
|
||||||
|
if undeclared.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("create on a relation without the create button status=%d body=%s", undeclared.Code, undeclared.Body.String())
|
||||||
|
}
|
||||||
|
missing := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", b+1000), map[string]any{"label": "x"}, true)
|
||||||
|
if missing.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("create under a missing parent status=%d body=%s", missing.Code, missing.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
265
modules/cabana/relation_form.go
Normal file
265
modules/cabana/relation_form.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
)
|
||||||
|
|
||||||
|
// relationFormRefusedTypes are the field types a relation form (manage.form,
|
||||||
|
// view.form, pivot.form) may not use (D-23): pickers, nested managers and
|
||||||
|
// plugin extensions need a parent record scope a child modal does not have.
|
||||||
|
var relationFormRefusedTypes = map[string]bool{
|
||||||
|
"relation": true, "relation-manager": true, "widget": true, "partial": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// pivotFieldPattern is WinterCMS's pivot form field name, pivot[column].
|
||||||
|
var pivotFieldPattern = regexp.MustCompile(`^pivot\[([A-Za-z_][A-Za-z0-9_]*)\]$`)
|
||||||
|
|
||||||
|
// pivotFieldName normalizes pivot[x] to x; any other name is unchanged.
|
||||||
|
func pivotFieldName(name string) string {
|
||||||
|
if m := pivotFieldPattern.FindStringSubmatch(name); m != nil {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// relationModelController stands in for an admin controller around a
|
||||||
|
// relation form's model (the related model, or the pivot model), so the
|
||||||
|
// controller form pipeline (writable fields, file and date fields, fill,
|
||||||
|
// validate, projection) runs against that model. Its ID is the owning
|
||||||
|
// controller's, so boot errors and lifecycle failures name it.
|
||||||
|
type relationModelController struct {
|
||||||
|
id string
|
||||||
|
newRecord func() any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c relationModelController) ID() string { return c.id }
|
||||||
|
func (c relationModelController) ModelName() string { return "" }
|
||||||
|
func (c relationModelController) ConfigDir() string { return "" }
|
||||||
|
func (c relationModelController) NewRecord() any { return c.newRecord() }
|
||||||
|
|
||||||
|
// compileRelationForms compiles a relation's forms (D-11, D-14, D-23): the
|
||||||
|
// manage form (manage.form, else the top-level form) used to create and
|
||||||
|
// update children, the view form (view.form, else the top-level form) used
|
||||||
|
// to preview them, and the pivot form (pivot.form, belongsToMany only). It
|
||||||
|
// also enforces the toolbar rules that depend on them: create and update
|
||||||
|
// need a manage form, and unlink on a hasMany needs a nullable ForeignKey.
|
||||||
|
func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS, file string, doc relationDocument, cr *CompiledRelation) error {
|
||||||
|
name := cr.Contract.Name
|
||||||
|
fail := func(err error) error {
|
||||||
|
return bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s: %w", name, err))
|
||||||
|
}
|
||||||
|
buttons := cr.Schema.View.ToolbarButtons
|
||||||
|
writes := slices.Contains(buttons, "create") || slices.Contains(buttons, "update")
|
||||||
|
manageRef := strings.TrimSpace(doc.Manage.Form)
|
||||||
|
if manageRef == "" {
|
||||||
|
manageRef = strings.TrimSpace(doc.Form)
|
||||||
|
}
|
||||||
|
viewRef := strings.TrimSpace(doc.View.Form)
|
||||||
|
if viewRef == "" {
|
||||||
|
viewRef = strings.TrimSpace(doc.Form)
|
||||||
|
}
|
||||||
|
if writes && manageRef == "" {
|
||||||
|
return fail(errors.New("toolbar buttons create and update need manage.form (or a top-level form)"))
|
||||||
|
}
|
||||||
|
if cr.hasMany() && slices.Contains(buttons, "unlink") && !cr.deferrable {
|
||||||
|
return fail(fmt.Errorf("unlink needs a nullable ForeignKey %s (a pointer field)", cr.Contract.ForeignKey))
|
||||||
|
}
|
||||||
|
cr.Schema.relatedModel = cr.Contract.NewRelated
|
||||||
|
if manageRef != "" {
|
||||||
|
child, err := compileRelationForm(pluginID, ctl, fsys, file, manageRef, cr, "manage", writes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cr.child = child
|
||||||
|
cr.Schema.manageForm = child.Form
|
||||||
|
cr.Schema.ManageForm = child.Form.Fields
|
||||||
|
}
|
||||||
|
if viewRef != "" {
|
||||||
|
view := cr.child
|
||||||
|
if viewRef != manageRef || view == nil {
|
||||||
|
compiled, err := compileRelationForm(pluginID, ctl, fsys, file, viewRef, cr, "view", false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
view = compiled
|
||||||
|
}
|
||||||
|
cr.view = view
|
||||||
|
cr.Schema.viewForm = view.Form
|
||||||
|
cr.Schema.ViewForm = view.Form.Fields
|
||||||
|
}
|
||||||
|
if doc.Pivot != nil {
|
||||||
|
ref := strings.TrimSpace(doc.Pivot.Form)
|
||||||
|
if ref == "" {
|
||||||
|
return fail(errors.New("pivot needs a form"))
|
||||||
|
}
|
||||||
|
if cr.hasMany() {
|
||||||
|
return fail(errors.New("pivot.form is only valid on a belongsToMany relation"))
|
||||||
|
}
|
||||||
|
pivot, err := compileRelationForm(pluginID, ctl, fsys, file, ref, cr, "pivot", false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cr.pivot = pivot
|
||||||
|
cr.Schema.pivotForm = pivot.Form
|
||||||
|
cr.Schema.PivotForm = pivot.Form.Fields
|
||||||
|
cr.Schema.pivotModel = cr.Contract.NewPivot
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileRelationForm reads one relation form file and binds it to its
|
||||||
|
// model: the related model for the manage and view forms, the pivot model
|
||||||
|
// for the pivot form. writable additionally requires the model to implement
|
||||||
|
// lagoon.HasFillable and Rules (create and update fill and validate it).
|
||||||
|
func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS, cfgFile, ref string, cr *CompiledRelation, purpose string, writable bool) (*CompiledController, error) {
|
||||||
|
relation := cr.Contract.Name
|
||||||
|
path, err := assetPath(pluginID, ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), cfgFile, fmt.Errorf("relation %s %s form: %w", relation, purpose, err))
|
||||||
|
}
|
||||||
|
fail := func(err error) error {
|
||||||
|
return bootErr(pluginID, ctl.ID(), path, fmt.Errorf("relation %s %s form: %w", relation, purpose, err))
|
||||||
|
}
|
||||||
|
raw, err := readAsset(fsys, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fail(err)
|
||||||
|
}
|
||||||
|
var fields []FormField
|
||||||
|
if purpose == "pivot" {
|
||||||
|
fields, err = decodePivotFields(raw)
|
||||||
|
} else {
|
||||||
|
fields, err = decodeFields(raw)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fail(err)
|
||||||
|
}
|
||||||
|
newModel := cr.Contract.NewRelated
|
||||||
|
if purpose == "pivot" {
|
||||||
|
newModel = cr.Contract.NewPivot
|
||||||
|
}
|
||||||
|
model := newModel()
|
||||||
|
for _, field := range fields {
|
||||||
|
if relationFormRefusedTypes[field.Type] {
|
||||||
|
return nil, fail(fmt.Errorf("field %s: type %s is not supported in a relation form (%s)", field.Name, field.Type, purpose))
|
||||||
|
}
|
||||||
|
if cr.hasMany() && field.Name == cr.Contract.ForeignKey {
|
||||||
|
return nil, fail(fmt.Errorf("field %s is the relation's ForeignKey; the server sets it", field.Name))
|
||||||
|
}
|
||||||
|
if purpose == "pivot" {
|
||||||
|
if !scalarFormField(field.Type) {
|
||||||
|
return nil, fail(fmt.Errorf("field %s: type %s is not supported in a pivot form", field.Name, field.Type))
|
||||||
|
}
|
||||||
|
if protectedPivotColumn(field.Name, cr.Contract) || slices.Contains(cr.Contract.HookPivotColumns, field.Name) {
|
||||||
|
return nil, fail(fmt.Errorf("field %s is a server-owned pivot column", field.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if field.optionsMethod != "" && modelDropdownProvider(model) == nil {
|
||||||
|
return nil, fail(fmt.Errorf("dropdown method %s requires DropdownOptions on the %s model", field.optionsMethod, purpose))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cc := &CompiledController{
|
||||||
|
PluginID: pluginID,
|
||||||
|
Controller: relationModelController{id: ctl.ID(), newRecord: newModel},
|
||||||
|
Form: &FormSchema{Fields: fields, fieldsPath: path},
|
||||||
|
}
|
||||||
|
if err := BindWritableFields(cc); err != nil {
|
||||||
|
return nil, fail(errors.New(strings.TrimPrefix(err.Error(), "cabana: controller "+ctl.ID()+": ")))
|
||||||
|
}
|
||||||
|
if writable {
|
||||||
|
if _, err := newWritableModel(cc); err != nil {
|
||||||
|
return nil, fail(errors.New("the related model must implement lagoon.HasFillable and Rules"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if purpose == "pivot" {
|
||||||
|
if err := checkFormDates(pluginID, cc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cc, nil
|
||||||
|
}
|
||||||
|
if err := compileFileFields(pluginID, cc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if writable {
|
||||||
|
if err := compileDateFields(pluginID, cc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cc, nil
|
||||||
|
}
|
||||||
|
if err := checkFormDates(pluginID, cc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFormDates runs the D-19 Go type check of every datepicker field and
|
||||||
|
// records them, without requiring a fillable model (read-only and pivot
|
||||||
|
// forms).
|
||||||
|
func checkFormDates(pluginID string, cc *CompiledController) error {
|
||||||
|
model := cc.Controller.(pact.AdminRecordSource).NewRecord()
|
||||||
|
for _, field := range cc.Form.Fields {
|
||||||
|
if field.Type != "datepicker" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := checkDateType(model, field); err != nil {
|
||||||
|
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, err)
|
||||||
|
}
|
||||||
|
if cc.dates == nil {
|
||||||
|
cc.dates = map[string]*compiledDate{}
|
||||||
|
}
|
||||||
|
cc.dates[field.Name] = &compiledDate{
|
||||||
|
name: field.Name, mode: field.Mode,
|
||||||
|
min: field.MinDate, max: field.MaxDate,
|
||||||
|
ignoreTimezone: field.IgnoreTimezone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pivotFieldsFile is a pivot form's fields.yaml: WinterCMS names its fields
|
||||||
|
// pivot[column], which compile to the bare column name.
|
||||||
|
type pivotFieldsFile struct {
|
||||||
|
Fields pivotFieldMap `yaml:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pivotFieldMap struct {
|
||||||
|
items []FormField
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *pivotFieldMap) UnmarshalYAML(node ast.Node) error {
|
||||||
|
items, err := decodeFieldMapping(node, pivotFieldName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.items = items
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePivotFields(raw []byte) ([]FormField, error) {
|
||||||
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||||
|
var doc pivotFieldsFile
|
||||||
|
if err := dec.Decode(&doc); err != nil {
|
||||||
|
return nil, normalizeYAMLError(err)
|
||||||
|
}
|
||||||
|
if doc.Fields.items == nil {
|
||||||
|
return []FormField{}, nil
|
||||||
|
}
|
||||||
|
return doc.Fields.items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelDropdownProvider is model as a pact.DropdownOptionsProvider, or nil.
|
||||||
|
func modelDropdownProvider(model any) pact.DropdownOptionsProvider {
|
||||||
|
if p, ok := model.(pact.DropdownOptionsProvider); ok && p != nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -34,10 +34,22 @@ func readAsset(fsys fs.FS, name string) ([]byte, error) {
|
|||||||
return fs.ReadFile(fsys, name)
|
return fs.ReadFile(fsys, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assetPath resolves a YAML file reference to a path inside the plugin's
|
||||||
|
// AdminFS: a plugin-relative path, ~/plugins/<vendor>/<plugin>/rest, or
|
||||||
|
// WinterCMS's $/<vendor>/<plugin>/rest ($/ is the plugins directory). A $/
|
||||||
|
// path into another plugin cannot be read from this plugin's tree and is an
|
||||||
|
// error.
|
||||||
func assetPath(pluginID, ref string) (string, error) {
|
func assetPath(pluginID, ref string) (string, error) {
|
||||||
ref = strings.TrimSpace(ref)
|
ref = strings.TrimSpace(ref)
|
||||||
|
own := strings.ReplaceAll(pluginID, ".", "/") + "/"
|
||||||
|
if rest, ok := strings.CutPrefix(ref, "$/"); ok {
|
||||||
|
if !strings.HasPrefix(rest, own) {
|
||||||
|
return "", fmt.Errorf("$/ path %s names another plugin", ref)
|
||||||
|
}
|
||||||
|
ref = strings.TrimPrefix(rest, own)
|
||||||
|
}
|
||||||
ref = strings.TrimPrefix(ref, "~/")
|
ref = strings.TrimPrefix(ref, "~/")
|
||||||
prefix := "plugins/" + strings.ReplaceAll(pluginID, ".", "/") + "/"
|
prefix := "plugins/" + own
|
||||||
ref = strings.TrimPrefix(ref, prefix)
|
ref = strings.TrimPrefix(ref, prefix)
|
||||||
ref = path.Clean(ref)
|
ref = path.Clean(ref)
|
||||||
if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") {
|
if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") {
|
||||||
|
|||||||
@@ -264,6 +264,10 @@ type FormField struct {
|
|||||||
// Protected is true for a fileupload field whose relation is not
|
// Protected is true for a fileupload field whose relation is not
|
||||||
// public: its files are served only through the admin file routes.
|
// public: its files are served only through the admin file routes.
|
||||||
Protected bool `json:"protected,omitempty"`
|
Protected bool `json:"protected,omitempty"`
|
||||||
|
// 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 bool `json:"deferrable,omitempty"`
|
||||||
|
|
||||||
optionsMethod string
|
optionsMethod string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ var phase09Routes = []adminRoute{
|
|||||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"},
|
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"},
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
|
||||||
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records"},
|
||||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute},
|
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"},
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
|
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
|
||||||
@@ -293,6 +294,7 @@ func phase09ProtectedCalls() []phase09Call {
|
|||||||
{"relation-candidates", (*service).relationCandidates},
|
{"relation-candidates", (*service).relationCandidates},
|
||||||
{"relation-link", (*service).relationLink},
|
{"relation-link", (*service).relationLink},
|
||||||
{"relation-unlink", (*service).relationUnlink},
|
{"relation-unlink", (*service).relationUnlink},
|
||||||
|
{"relation-child-create", (*service).relationChildCreate},
|
||||||
{"file-list", (*service).fileList},
|
{"file-list", (*service).fileList},
|
||||||
{"file-upload", (*service).fileUpload},
|
{"file-upload", (*service).fileUpload},
|
||||||
{"file-reorder", (*service).fileReorder},
|
{"file-reorder", (*service).fileReorder},
|
||||||
|
|||||||
@@ -136,3 +136,22 @@ messages:
|
|||||||
one: "Unlinked :count record"
|
one: "Unlinked :count record"
|
||||||
other: "Unlinked :count records"
|
other: "Unlinked :count records"
|
||||||
empty: No linked records.
|
empty: No linked records.
|
||||||
|
create: New record
|
||||||
|
create_title: New record
|
||||||
|
update_title: Edit record
|
||||||
|
preview_title: Record preview
|
||||||
|
created: Record created
|
||||||
|
updated: Record saved
|
||||||
|
delete_selected: Delete selected
|
||||||
|
delete_confirm: "Delete the selected (:count)? This cannot be undone."
|
||||||
|
delete_one_confirm: Delete this record? This cannot be undone.
|
||||||
|
deleted:
|
||||||
|
one: "Deleted :count record"
|
||||||
|
other: "Deleted :count records"
|
||||||
|
pivot_title: Link details
|
||||||
|
pivot_saved: Link details saved
|
||||||
|
edit_pivot: "Edit link details: :name"
|
||||||
|
create_submit: Create record
|
||||||
|
update_submit: Save record
|
||||||
|
pivot_submit: Save link details
|
||||||
|
link_submit: Add link
|
||||||
|
|||||||
@@ -152,3 +152,24 @@ messages:
|
|||||||
many: "Odłączono :count rekordów"
|
many: "Odłączono :count rekordów"
|
||||||
other: "Odłączono :count rekordu"
|
other: "Odłączono :count rekordu"
|
||||||
empty: Brak powiązanych rekordów.
|
empty: Brak powiązanych rekordów.
|
||||||
|
create: Nowy rekord
|
||||||
|
create_title: Nowy rekord
|
||||||
|
update_title: Edycja rekordu
|
||||||
|
preview_title: Podgląd rekordu
|
||||||
|
created: Utworzono rekord
|
||||||
|
updated: Zapisano rekord
|
||||||
|
delete_selected: Usuń zaznaczone
|
||||||
|
delete_confirm: "Usunąć zaznaczone (:count)? Tej operacji nie można cofnąć."
|
||||||
|
delete_one_confirm: Usunąć ten rekord? Tej operacji nie można cofnąć.
|
||||||
|
deleted:
|
||||||
|
one: "Usunięto :count rekord"
|
||||||
|
few: "Usunięto :count rekordy"
|
||||||
|
many: "Usunięto :count rekordów"
|
||||||
|
other: "Usunięto :count rekordu"
|
||||||
|
pivot_title: Szczegóły powiązania
|
||||||
|
pivot_saved: Zapisano szczegóły powiązania
|
||||||
|
edit_pivot: "Edytuj szczegóły powiązania: :name"
|
||||||
|
create_submit: Utwórz rekord
|
||||||
|
update_submit: Zapisz rekord
|
||||||
|
pivot_submit: Zapisz szczegóły powiązania
|
||||||
|
link_submit: Dodaj powiązanie
|
||||||
|
|||||||
Reference in New Issue
Block a user