diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 9f0bdc5..ef04f21 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -615,6 +615,10 @@ "default": { "$ref": "#/components/schemas/cabana.jsonScalar" }, + "deferrable": { + "description": "Deferrable is true on a relation-manager field whose relation can be\nmanaged before the record is first saved (RelationSchema.Deferrable):\nthe SPA shows it on the create screen.", + "type": "boolean" + }, "displayFormat": { "type": "string" }, @@ -1380,6 +1384,33 @@ "candidateSearch": { "$ref": "#/components/schemas/cabana.MessageForms" }, + "create": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "createSubmit": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "createTitle": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "created": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "deleteConfirm": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "deleteOneConfirm": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "deleteSelected": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "deleted": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "editPivot": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, "empty": { "$ref": "#/components/schemas/cabana.MessageForms" }, @@ -1389,9 +1420,24 @@ "linkHint": { "$ref": "#/components/schemas/cabana.MessageForms" }, + "linkSubmit": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, "linked": { "$ref": "#/components/schemas/cabana.MessageForms" }, + "pivotSaved": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "pivotSubmit": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "pivotTitle": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "previewTitle": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, "unlinkConfirm": { "$ref": "#/components/schemas/cabana.MessageForms" }, @@ -1400,17 +1446,43 @@ }, "unlinked": { "$ref": "#/components/schemas/cabana.MessageForms" + }, + "updateSubmit": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "updateTitle": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "updated": { + "$ref": "#/components/schemas/cabana.MessageForms" } }, "required": [ "candidateSearch", + "create", + "createSubmit", + "createTitle", + "created", + "deleteConfirm", + "deleteOneConfirm", + "deleteSelected", + "deleted", + "editPivot", "empty", "link", "linkHint", + "linkSubmit", "linked", + "pivotSaved", + "pivotSubmit", + "pivotTitle", + "previewTitle", "unlinkConfirm", "unlinkSelected", - "unlinked" + "unlinked", + "updateSubmit", + "updateTitle", + "updated" ], "type": "object" }, @@ -1464,12 +1536,27 @@ }, "cabana.RelationSchema": { "properties": { + "deferrable": { + "description": "Deferrable is true when the relation can be managed on a record that\nis not saved yet: always for belongsToMany, and for a hasMany whose\nForeignKey is nullable.", + "type": "boolean" + }, + "kind": { + "description": "Kind is the contract kind: belongsToMany or hasMany.", + "type": "string" + }, "label": { "type": "string" }, "manage": { "$ref": "#/components/schemas/cabana.RelationPanel" }, + "manageForm": { + "description": "ManageForm is the child create and update form (manage.form, or the\ntop-level form); ViewForm is the read-only preview form (view.form,\nor the top-level form); PivotForm edits pivot columns of a\nbelongsToMany link (pivot.form). Each is omitted when not declared.", + "items": { + "$ref": "#/components/schemas/cabana.FormField" + }, + "type": "array" + }, "messages": { "allOf": [ { @@ -1481,11 +1568,25 @@ "name": { "type": "string" }, + "pivotForm": { + "items": { + "$ref": "#/components/schemas/cabana.FormField" + }, + "type": "array" + }, "view": { "$ref": "#/components/schemas/cabana.RelationPanel" + }, + "viewForm": { + "items": { + "$ref": "#/components/schemas/cabana.FormField" + }, + "type": "array" } }, "required": [ + "deferrable", + "kind", "label", "manage", "messages", @@ -5104,6 +5205,140 @@ ] } }, + "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": { + "post": { + "description": "Creates a record through the relation's manage form (manage.form, or the top-level form of config_relation.yaml) and attaches it to the owner: a hasMany child gets the owner's key in its foreign key (the server sets it; the body cannot), a belongsToMany record gets a pivot row. The view panel must declare the create toolbar button, otherwise 403. The owner is scoped like the record show route.", + "parameters": [ + { + "description": "Vendor", + "in": "path", + "name": "vendor", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Plugin", + "in": "path", + "name": "plugin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Controller", + "in": "path", + "name": "controller", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Owner id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Relation name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminRecord" + } + } + }, + "description": "Field values of the manage form keyed by field name", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.RecordEnvelope" + } + } + }, + "description": "Created" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Request Entity Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Create a related record", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": { "post": { "parameters": [ diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index e8ff973..8268325 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -2628,6 +2628,106 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a related record + * @description Creates a record through the relation's manage form (manage.form, or the top-level form of config_relation.yaml) and attaches it to the owner: a hasMany child gets the owner's key in its foreign key (the server sets it; the body cannot), a belongsToMany record gets a pivot row. The view panel must declare the create toolbar button, otherwise 403. The owner is scoped like the record show route. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Owner id */ + id: number; + /** @description Relation name */ + name: string; + }; + cookie?: never; + }; + /** @description Field values of the manage form keyed by field name */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminRecord"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.RecordEnvelope"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": { parameters: { query?: never; @@ -2893,6 +2993,12 @@ export interface components { comment?: string; context?: components["schemas"]["cabana.fieldContext"]; default?: components["schemas"]["cabana.jsonScalar"]; + /** + * @description Deferrable is true on a relation-manager field whose relation can be + * managed before the record is first saved (RelationSchema.Deferrable): + * the SPA shows it on the create screen. + */ + deferrable?: boolean; displayFormat?: string; emptyOption?: string; /** @description FileTypes are the allowed lower-case extensions of a fileupload field. */ @@ -3143,13 +3249,30 @@ export interface components { }; "cabana.RelationMessages": { candidateSearch: components["schemas"]["cabana.MessageForms"]; + create: components["schemas"]["cabana.MessageForms"]; + createSubmit: components["schemas"]["cabana.MessageForms"]; + createTitle: components["schemas"]["cabana.MessageForms"]; + created: components["schemas"]["cabana.MessageForms"]; + deleteConfirm: components["schemas"]["cabana.MessageForms"]; + deleteOneConfirm: components["schemas"]["cabana.MessageForms"]; + deleteSelected: components["schemas"]["cabana.MessageForms"]; + deleted: components["schemas"]["cabana.MessageForms"]; + editPivot: components["schemas"]["cabana.MessageForms"]; empty: components["schemas"]["cabana.MessageForms"]; link: components["schemas"]["cabana.MessageForms"]; linkHint: components["schemas"]["cabana.MessageForms"]; + linkSubmit: components["schemas"]["cabana.MessageForms"]; linked: components["schemas"]["cabana.MessageForms"]; + pivotSaved: components["schemas"]["cabana.MessageForms"]; + pivotSubmit: components["schemas"]["cabana.MessageForms"]; + pivotTitle: components["schemas"]["cabana.MessageForms"]; + previewTitle: components["schemas"]["cabana.MessageForms"]; unlinkConfirm: components["schemas"]["cabana.MessageForms"]; unlinkSelected: components["schemas"]["cabana.MessageForms"]; unlinked: components["schemas"]["cabana.MessageForms"]; + updateSubmit: components["schemas"]["cabana.MessageForms"]; + updateTitle: components["schemas"]["cabana.MessageForms"]; + updated: components["schemas"]["cabana.MessageForms"]; }; "cabana.RelationMutationResult": { linked?: number; @@ -3165,15 +3288,32 @@ export interface components { toolbarButtons: string[]; }; "cabana.RelationSchema": { + /** + * @description Deferrable is true when the relation can be managed on a record that + * is not saved yet: always for belongsToMany, and for a hasMany whose + * ForeignKey is nullable. + */ + deferrable: boolean; + /** @description Kind is the contract kind: belongsToMany or hasMany. */ + kind: string; label: string; manage: components["schemas"]["cabana.RelationPanel"]; + /** + * @description ManageForm is the child create and update form (manage.form, or the + * top-level form); ViewForm is the read-only preview form (view.form, + * or the top-level form); PivotForm edits pivot columns of a + * belongsToMany link (pivot.form). Each is omitted when not declared. + */ + manageForm?: components["schemas"]["cabana.FormField"][]; /** * @description Messages is the relation manager's copy (D-13); the cached schema * carries each phrase key as its own form. */ messages: components["schemas"]["cabana.RelationMessages"]; name: string; + pivotForm?: components["schemas"]["cabana.FormField"][]; view: components["schemas"]["cabana.RelationPanel"]; + viewForm?: components["schemas"]["cabana.FormField"][]; }; "cabana.RowAction": { label?: string; diff --git a/admin/tests/fixtures/widgets.relation-schema.json b/admin/tests/fixtures/widgets.relation-schema.json index b541a1d..166f403 100644 --- a/admin/tests/fixtures/widgets.relation-schema.json +++ b/admin/tests/fixtures/widgets.relation-schema.json @@ -2,6 +2,8 @@ "data": { "name": "members", "label": "Members", + "kind": "belongsToMany", + "deferrable": true, "view": { "list": { "columns": [ @@ -72,6 +74,58 @@ }, "empty": { "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" } } }, diff --git a/docs/backend/relation-manager.md b/docs/backend/relation-manager.md index fd4269a..0b16d5b 100644 --- a/docs/backend/relation-manager.md +++ b/docs/backend/relation-manager.md @@ -1,6 +1,6 @@ --- title: Relation manager -description: Edit belongsTo and belongsToMany relations in admin forms and manage linked records with config_relation.yaml, bound to models the controller names. +description: Edit belongsTo and belongsToMany fields, and manage linked and hasMany records with config_relation.yaml, bound to models the controller names. section: backend order: 40 --- @@ -49,7 +49,79 @@ editors: The controller implements `cabana.AdminRelationContractProvider` and returns a `cabana.RelationContract` per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a `relation-manager` field stops the start-up. -`cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. The `view` panel's `toolbarButtons` decide which of the two the server accepts: a relation that does not list `unlink` answers 403 `forbidden` on the unlink route, and likewise for `link`. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written. +`cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written. + +### Relation kinds + +`RelationContract.Kind` says how the related records hang off the parent: + +- `belongsToMany` (`cabana.RelationBelongsToMany`) links records through a pivot model: `NewPivot`, `ParentForeignKey`, `RelatedForeignKey` and optionally `HookPivotColumns`. A contract that leaves `Kind` empty is a belongsToMany, so contracts written before hasMany existed keep working unchanged. +- `hasMany` (`cabana.RelationHasMany`) owns records through a column on the related model: `ForeignKey` names it. A hasMany contract declares no pivot fields; a pivot field, a `ForeignKey` that is not an integer column of the related model, or an unknown kind stops the start-up. + +```go src=modules/cabana/example_relation_test.go#PostsController.AdminRelationContracts +// AdminRelationContracts binds the posts form's relation managers to their +// models; the framework never guesses a table or column. +func (PostsController) AdminRelationContracts() []cabana.RelationContract { + return []cabana.RelationContract{{ + Name: "comments", + Kind: cabana.RelationHasMany, + NewRelated: func() any { return &Comment{} }, + ForeignKey: "post_id", + Columns: map[string]string{"author": "author", "body": "body"}, + }} +} +``` + +A hasMany `ForeignKey` of a pointer type (`*uint`, a nullable column) is needed for `unlink` and for managing the relation before the parent is saved. + +### Relation forms + +The relation manager creates and edits related records in a modal through a form of their own. `manage.form` names the form used to create and update a child, `view.form` the read-only preview; a top-level `form` is the fallback of both, as in WinterCMS: + +```yaml +comments: + label: acme.blog::lang.posts.comments + form: $/acme/blog/models/comment/fields.yaml + view: + list: + columns: + author: + label: acme.blog::lang.comments.author + toolbarButtons: create|update|delete + manage: + list: + columns: + author: + label: acme.blog::lang.comments.author +``` + +A form path is plugin-relative, `~/plugins///...`, or WinterCMS's `$///...`. 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.`. 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 diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 3e20544..ea8f085 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -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. - 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. -- 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 `$///...` 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. - 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. @@ -48,6 +48,7 @@ All paths are relative to `/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 `.../{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}/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`. | | 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). | @@ -163,6 +164,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. | | `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. | | `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.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. | @@ -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. | | `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.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. diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 3328991..75590c4 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -612,6 +612,29 @@ func AdminRelationLink() {} // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post] 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 // removed (always 1 on success). type FileMutationResult struct { diff --git a/modules/cabana/datepicker_smoke_test.go b/modules/cabana/datepicker_smoke_test.go index 56de3a4..d310f36 100644 --- a/modules/cabana/datepicker_smoke_test.go +++ b/modules/cabana/datepicker_smoke_test.go @@ -31,6 +31,9 @@ func datepickerFields(extra string) string { type: relation-manager relation: members context: [update] + parts: + type: relation-manager + relation: parts lookup: label: Lookup type: widget diff --git a/modules/cabana/example_relation_test.go b/modules/cabana/example_relation_test.go new file mode 100644 index 0000000..ad4206f --- /dev/null +++ b/modules/cabana/example_relation_test.go @@ -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"}, + }} +} diff --git a/modules/cabana/form_schema.go b/modules/cabana/form_schema.go index aa4502b..745e82c 100644 --- a/modules/cabana/form_schema.go +++ b/modules/cabana/form_schema.go @@ -304,34 +304,47 @@ func decodeFields(raw []byte) ([]FormField, 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) if _, ok := node.(*ast.NullNode); ok || node == nil { - m.items = []FormField{} - return nil + return []FormField{}, nil } mapping, ok := node.(*ast.MappingNode) 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)) seen := map[string]struct{}{} for _, entry := range mapping.Values { name, err := nodeString(unwrapNode(entry.Key)) + if err == nil && rename != nil { + name = rename(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 { - return fmt.Errorf("duplicate field %s", name) + return nil, fmt.Errorf("duplicate field %s", name) } seen[name] = struct{}{} field, err := compileFieldNode(name, unwrapNode(entry.Value)) if err != nil { - return fmt.Errorf("field %s: %w", name, err) + return nil, fmt.Errorf("field %s: %w", name, err) } items = append(items, field) } - m.items = items - return nil + return items, nil } func compileFieldNode(name string, node ast.Node) (FormField, error) { diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 2d23615..922056f 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -275,6 +275,10 @@ func (s *service) mount(r pact.Router) { constrainRelation(g) g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink)) 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 // record being created in the X-Session-Key session. g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload)) @@ -577,7 +581,7 @@ func (s *service) relations() (RelationService, error) { if err != nil { 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) { diff --git a/modules/cabana/messages.go b/modules/cabana/messages.go index 2240206..6994e49 100644 --- a/modules/cabana/messages.go +++ b/modules/cabana/messages.go @@ -81,18 +81,53 @@ type relationMessageKeys struct { UnlinkConfirm string `yaml:"unlinkConfirm"` Unlinked string `yaml:"unlinked"` 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. type RelationMessages struct { - Link MessageForms `json:"link"` - LinkHint MessageForms `json:"linkHint"` - CandidateSearch MessageForms `json:"candidateSearch"` - Linked MessageForms `json:"linked"` - UnlinkSelected MessageForms `json:"unlinkSelected"` - UnlinkConfirm MessageForms `json:"unlinkConfirm"` - Unlinked MessageForms `json:"unlinked"` - Empty MessageForms `json:"empty"` + Link MessageForms `json:"link"` + LinkHint MessageForms `json:"linkHint"` + CandidateSearch MessageForms `json:"candidateSearch"` + Linked MessageForms `json:"linked"` + UnlinkSelected MessageForms `json:"unlinkSelected"` + UnlinkConfirm MessageForms `json:"unlinkConfirm"` + Unlinked MessageForms `json:"unlinked"` + 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. @@ -117,14 +152,31 @@ var ( Deleted: "backend::lang.messages.form.deleted", } relationMessageDefaults = relationMessageKeys{ - Link: "backend::lang.messages.relation.link", - LinkHint: "backend::lang.messages.relation.link_hint", - CandidateSearch: "backend::lang.messages.relation.candidate_search", - Linked: "backend::lang.messages.relation.linked", - UnlinkSelected: "backend::lang.messages.relation.unlink_selected", - UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm", - Unlinked: "backend::lang.messages.relation.unlinked", - Empty: "backend::lang.messages.relation.empty", + Link: "backend::lang.messages.relation.link", + LinkHint: "backend::lang.messages.relation.link_hint", + CandidateSearch: "backend::lang.messages.relation.candidate_search", + Linked: "backend::lang.messages.relation.linked", + UnlinkSelected: "backend::lang.messages.relation.unlink_selected", + UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm", + Unlinked: "backend::lang.messages.relation.unlinked", + 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", } ) diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index cb7be0c..8193a64 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -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 { 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}, + {"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 { return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true) }, into[cabana.Envelope[cabana.AdminActionResult]](), nil}, @@ -474,7 +477,7 @@ func dataID(t *testing.T, raw []byte) uint { func newConformEnv(t *testing.T) *conformEnv { t.Helper() 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 { t.Fatal(err) } @@ -543,6 +546,7 @@ type conformGadget struct { ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"` StartsAt *time.Time `gorm:"column:starts_at"` Members []conformMember `gorm:"-"` + Parts []conformPart `gorm:"-"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } @@ -586,6 +590,20 @@ type conformGadgetMember struct { 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 { ID uint `gorm:"column:id;primaryKey"` Enabled bool `gorm:"column:enabled"` @@ -630,6 +648,9 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract { return []cabana.RelationContract{{ Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} }, 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 { @@ -748,6 +769,20 @@ update: email: label: Email 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: name: @@ -779,6 +814,9 @@ update: type: relation-manager relation: members context: [update] + parts: + type: relation-manager + relation: parts lookup: label: Lookup type: widget @@ -814,6 +852,12 @@ update: fileTypes: [pdf, png, svg, txt] useCaption: true context: update +`), + "models/part/fields.yaml": file(`fields: + label: + label: Label + type: text + required: true `), "models/settings/fields.yaml": file(`fields: enabled: diff --git a/modules/cabana/phase10_coverage_test.go b/modules/cabana/phase10_coverage_test.go index 9531c24..011840a 100644 --- a/modules/cabana/phase10_coverage_test.go +++ b/modules/cabana/phase10_coverage_test.go @@ -100,13 +100,14 @@ func TestPhase10Coverage(t *testing.T) { "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", + "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", "PUT /settings/{code}", "PUT /{vendor}/{plugin}/{controller}/{id}", "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", } 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 // nested pattern serving field options, filter options and relation lists. diff --git a/modules/cabana/phase10_csrf_test.go b/modules/cabana/phase10_csrf_test.go index ce0b69f..603e485 100644 --- a/modules/cabana/phase10_csrf_test.go +++ b/modules/cabana/phase10_csrf_test.go @@ -68,9 +68,9 @@ func TestPhase10CSRF(t *testing.T) { } // refresh, logout, settings put, create, bulk-delete, widget action, // toolbar action, update, delete, link, unlink, file upload, file - // reorder, file caption, file remove - if unsafe != 15 { - t.Fatalf("walked %d state-changing routes, want 15: %v", unsafe, router.order) + // reorder, file caption, file remove, relation child create + if unsafe != 16 { + t.Fatalf("walked %d state-changing routes, want 16: %v", unsafe, router.order) } loginHandler := router.handlers[login] diff --git a/modules/cabana/relation.go b/modules/cabana/relation.go index 4021cdf..5cb8f6d 100644 --- a/modules/cabana/relation.go +++ b/modules/cabana/relation.go @@ -7,12 +7,14 @@ import ( "fmt" "io/fs" "reflect" + "slices" "strings" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "github.com/goccy/go-yaml" + "gocloud.dev/blob" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -23,18 +25,46 @@ type AdminRelationContractProvider interface { 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. type RelationContract struct { - Name string - NewRelated func() any - NewPivot func() any - ParentForeignKey string - RelatedForeignKey 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 + // NewPivot, ParentForeignKey, RelatedForeignKey and HookPivotColumns + // describe the pivot of a belongsToMany relation; a hasMany contract + // leaves them empty. + NewPivot func() any + ParentForeignKey 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 HookPivotColumns []string 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. type RelationColumn struct { Key string `json:"key"` @@ -57,15 +87,35 @@ type RelationPanel struct { // RelationSchema is the cached locale-neutral relation contract. type RelationSchema struct { - Name string `json:"name"` - Label string `json:"label"` - View RelationPanel `json:"view"` - Manage RelationPanel `json:"manage"` + Name string `json:"name"` + 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"` + 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 // carries each phrase key as its own form. Messages *RelationMessages `json:"messages"` 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. @@ -73,6 +123,27 @@ type CompiledRelation struct { Schema *RelationSchema Contract RelationContract 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. @@ -102,7 +173,14 @@ type RelationMutationResult struct { } // 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) { 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) messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet()) 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 } +// 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 { out := src out.List.Columns = append([]RelationColumn(nil), src.List.Columns...) @@ -155,20 +254,29 @@ type relationRoot struct { } type relationDocument struct { - Label string `yaml:"label"` - View relationPanelDocument `yaml:"view"` - Manage relationPanelDocument `yaml:"manage"` - Messages *relationMessageKeys `yaml:"messages"` + 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"` + Manage relationPanelDocument `yaml:"manage"` + Pivot *relationPivotDocument `yaml:"pivot"` + Messages *relationMessageKeys `yaml:"messages"` } type relationPanelDocument struct { List struct { Columns yaml.MapSlice `yaml:"columns"` } `yaml:"list"` + Form string `yaml:"form"` ToolbarButtons string `yaml:"toolbarButtons"` ShowSearch bool `yaml:"showSearch"` } +type relationPivotDocument struct { + Form string `yaml:"form"` +} + type relationColumnDocument struct { Label string `yaml:"label"` 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) { - fields := map[string]FormField{} + // fields maps each relation-manager relation to its form field index. + fields := map[string]int{} if form != nil { - for _, field := range form.Fields { + for i, field := range form.Fields { 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 { 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 { schema.messageKeys = *doc.Messages } keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet()) schema.Messages = &keys - out[name] = &CompiledRelation{ + cr := &CompiledRelation{ Schema: schema, Contract: contract, 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 { 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 } +// 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) { if strings.TrimSpace(raw) == "" { return []string{}, nil @@ -320,11 +443,11 @@ func compileRelationButtons(raw string, view bool) ([]string, error) { seen := map[string]struct{}{} for _, part := range parts { part = strings.TrimSpace(part) - if part != "link" && part != "unlink" { + if !slices.Contains(relationButtons, part) { return nil, fmt.Errorf("unsupported relation action %s", part) } - if !view && part == "unlink" { - return nil, fmt.Errorf("manage panel cannot declare unlink") + if !view && part != "link" { + return nil, fmt.Errorf("manage panel cannot declare %s (only link)", part) } if _, dup := seen[part]; dup { 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 { - 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 !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) + switch contract.kind() { + case RelationBelongsToMany: + if err := validatePivotContract(contract); err != nil { + return err + } + case RelationHasMany: + if err := validateHasManyContract(contract); err != nil { + return err + } + default: + return fmt.Errorf("relation %s has unknown kind %q (want %s or %s)", contract.Name, contract.Kind, RelationBelongsToMany, RelationHasMany) } targetCols := modelColumns(contract.NewRelated()) 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) } } - 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) if !ok || src == nil || src.NewRecord() == nil { 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 } +// 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 { switch col { 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) { + if cr.hasMany() { + return hasManyBaseQuery(ctx, tx, cc, cr, parent, candidates) + } target := cr.Contract.NewRelated() targetTable := tableName(target) pivotTable := tableName(cr.Contract.NewPivot()) @@ -493,6 +675,33 @@ func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, 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 // a positive integer (default 1), per_page is 1..100 (default 20). 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 { return RelationMutationResult{}, err } + if cr.hasMany() { + return RelationMutationResult{}, recordNotFound{} + } var result RelationMutationResult err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) @@ -660,28 +872,7 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat } for i := 0; i < holder.Elem().Len(); i++ { related := holder.Elem().Index(i).Addr().Interface() - pivot := cr.Contract.NewPivot() - if err := setModelColumn(pivot, cr.Contract.ParentForeignKey, ownerPK); err != nil { - return err - } - if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil { - return err - } - values := map[string]any{} - if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil { - if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil { - return lifecycleFailure(cc, err) - } - } - for key, value := range values { - if !stringSliceHasValue(cr.Contract.HookPivotColumns, key) || protectedPivotColumn(key, cr.Contract) { - return lifecycleFailure(cc, fmt.Errorf("relation hook wrote protected pivot column")) - } - if err := setModelColumn(pivot, key, value); err != nil { - return lifecycleFailure(cc, err) - } - } - if err := tx.WithContext(ctx).Create(pivot).Error; err != nil { + if err := insertPivot(ctx, tx, cc, cr, parent, related, nil); err != nil { return err } result.Linked++ @@ -691,6 +882,37 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat 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 + } + if err := setModelColumn(pivot, cr.Contract.RelatedForeignKey, pkUint(related)); err != nil { + return err + } + values := map[string]any{} + if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil { + if err := hook.RelationBeforeLink(ctx, cr.Contract.Name, parent, related, values); err != nil { + return lifecycleFailure(cc, err) + } + } + for key, value := range values { + if !stringSliceHasValue(cr.Contract.HookPivotColumns, key) || protectedPivotColumn(key, cr.Contract) { + return lifecycleFailure(cc, fmt.Errorf("relation hook wrote protected pivot column")) + } + if err := setModelColumn(pivot, key, value); err != nil { + return lifecycleFailure(cc, err) + } + } + return tx.WithContext(ctx).Create(pivot).Error +} + // Unlink deletes explicit pivot models so their lifecycle hooks run. func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) { ids, err := normalizeIDs(in.IDs) @@ -701,6 +923,9 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel if err != nil { return RelationMutationResult{}, err } + if cr.hasMany() { + return RelationMutationResult{}, recordNotFound{} + } var result RelationMutationResult err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) diff --git a/modules/cabana/relation_child.go b/modules/cabana/relation_child.go new file mode 100644 index 0000000..86ba041 --- /dev/null +++ b/modules/cabana/relation_child.go @@ -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) + }) +} diff --git a/modules/cabana/relation_child_smoke_test.go b/modules/cabana/relation_child_smoke_test.go new file mode 100644 index 0000000..ae8b7c7 --- /dev/null +++ b/modules/cabana/relation_child_smoke_test.go @@ -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()) + } +} diff --git a/modules/cabana/relation_form.go b/modules/cabana/relation_form.go new file mode 100644 index 0000000..b20acac --- /dev/null +++ b/modules/cabana/relation_form.go @@ -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 +} diff --git a/modules/cabana/schema.go b/modules/cabana/schema.go index 65b694c..d088144 100644 --- a/modules/cabana/schema.go +++ b/modules/cabana/schema.go @@ -34,10 +34,22 @@ func readAsset(fsys fs.FS, name string) ([]byte, error) { return fs.ReadFile(fsys, name) } +// assetPath resolves a YAML file reference to a path inside the plugin's +// AdminFS: a plugin-relative path, ~/plugins///rest, or +// WinterCMS's $///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) { 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, "~/") - prefix := "plugins/" + strings.ReplaceAll(pluginID, ".", "/") + "/" + prefix := "plugins/" + own ref = strings.TrimPrefix(ref, prefix) ref = path.Clean(ref) if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") { diff --git a/modules/cabana/schema_types.go b/modules/cabana/schema_types.go index 8407c75..3551f57 100644 --- a/modules/cabana/schema_types.go +++ b/modules/cabana/schema_types.go @@ -264,6 +264,10 @@ type FormField struct { // Protected is true for a fileupload field whose relation is not // public: its files are served only through the admin file routes. 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 } diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index 45b1e73..f6b5ea3 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -62,6 +62,7 @@ var phase09Routes = []adminRoute{ {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}/unlink"}, + {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records"}, {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}/reorder"}, @@ -293,6 +294,7 @@ func phase09ProtectedCalls() []phase09Call { {"relation-candidates", (*service).relationCandidates}, {"relation-link", (*service).relationLink}, {"relation-unlink", (*service).relationUnlink}, + {"relation-child-create", (*service).relationChildCreate}, {"file-list", (*service).fileList}, {"file-upload", (*service).fileUpload}, {"file-reorder", (*service).fileReorder}, diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index 7751e19..2d4b7e4 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -136,3 +136,22 @@ messages: one: "Unlinked :count record" other: "Unlinked :count 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 diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index 5206fea..6f5d1cd 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -152,3 +152,24 @@ messages: many: "Odłączono :count rekordów" other: "Odłączono :count rekordu" 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