feat(build): scaffold working admin controllers with model, AdminFS and delete (quick-261028-pq8)

- make:admin-controller edits the singular of its name (--model overrides):
  ModelName, modelClass and the YAML under models/<model>/
- NewRecord returns &models.<Model>{} when the model exists; otherwise the
  nil body stays and the command says to run make:model
- config_list.yaml offers create and delete with showCheckboxes
- registry.gen.go embeds controllers/*/*.yaml and models/*/*.yaml once they
  exist and exposes generatedAdminFS(); scaffolded plugins implement
  pact.AdminAssets through it, and a plugin without AdminFS gets a note
- blog example moves its YAML to models/post; docs and help text follow
This commit is contained in:
Jakub Zych
2026-10-28 19:15:23 +01:00
parent 70e4b4183c
commit 8bc18c90b6
22 changed files with 491 additions and 84 deletions

View File

@@ -152,9 +152,10 @@ func (p *BlogPlugin) Settings() []pact.SettingsItem {
```sh
summer make:admin-controller acme.blog Posts
summer make:admin-controller acme.blog People --model Person
```
The generated controller requires the permission `acme.blog.access_posts`, so declare it in the plugin's `pact.HasPermissions` or the admin API refuses to start with an unknown-permission error. Its `NewRecord` returns `nil` until you return the model, and the list and every write answer 500 until then. The controller ID maps to the admin API path: `acme.blog.posts` is served under `<prefix>/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md). The admin finds a model's columns through its struct fields, including those of an embedded struct such as `gorm.Model`. A field with a `gorm:"column:..."` tag is known by that column alone; an untagged field is known by GORM's default column name.
The controller edits the singular of its name, `Post` for `Posts`, unless `--model` names the model: `ModelName` and both `modelClass` values carry it, and `fields.yaml` and `columns.yaml` go under `models/post/`, as in WinterCMS. When the plugin's `models` package already declares the model, `NewRecord` returns it; otherwise `NewRecord` returns `nil` and the list and every write answer 500 until you run `summer make:model` and return the model. The list toolbar offers `create` and `delete`, with `showCheckboxes: true`, which the `delete` button needs. A plugin scaffolded with `summer make:plugin` implements `pact.AdminAssets`: its `AdminFS` returns `generatedAdminFS()`, the controller and model YAML `registry.gen.go` embeds; for a plugin without an `AdminFS` method the command prints a note. The generated controller requires the permission `acme.blog.access_posts`, so declare it in the plugin's `pact.HasPermissions` or the admin API refuses to start with an unknown-permission error. The controller ID maps to the admin API path: `acme.blog.posts` is served under `<prefix>/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md). The admin finds a model's columns through its struct fields, including those of an embedded struct such as `gorm.Model`. A field with a `gorm:"column:..."` tag is known by that column alone; an untagged field is known by GORM's default column name.
## Compilation at boot

View File

@@ -128,13 +128,13 @@ summer make:model Comment
| `summer make:migration` | `updates/<timestamp>_<name>.go` | An empty gormigrate migration with up and down steps to fill in. |
| `summer make:command` | `console/<name>.go` | A `bonfire.Command` named `<plugin>:<name>`, such as `blog:publish`, from a function that takes the `*backpack.App` the plugin kept from `Boot`. A scaffolded plugin's `Commands` returns `generatedCommands(p.app)`; when `plugin.go` still calls `generatedCommands()` without the app, the command notes that the function receives `nil`. |
| `summer make:job` | `jobs/<name>.go` | A typed job built with `conga.Job`; the plugin never imports the queue library. |
| `summer make:admin-controller` | `controllers/<name>.go`, `controllers/<name>/config_form.yaml`, `controllers/<name>/config_list.yaml`, `models/<name>/fields.yaml`, `models/<name>/columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration. It already requires the permission `<plugin>.access_<name>`, which you declare in the plugin's `Permissions()`, and its `NewRecord` returns `nil` until you return your model, so the screens stay closed until you finish it. |
| `summer make:admin-controller` | `controllers/<name>.go`, `controllers/<name>/config_form.yaml`, `controllers/<name>/config_list.yaml`, `models/<model>/fields.yaml`, `models/<model>/columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration for the model `<model>`: the singular of the name (`Posts` edits `Post`, with its YAML in `models/post/`), or the model `--model` names. When `models/` already declares the model, `NewRecord` returns it; otherwise it returns `nil` and the command says to run `make:model`, so the screens stay closed until you finish it. The list offers `create` and `delete` (`showCheckboxes: true`). The controller requires the permission `<plugin>.access_<name>`, which you declare in the plugin's `Permissions()`. |
File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit UTC timestamp. Each new migration gets a timestamp after every file already in `updates/`: the current second, or one second past the newest existing migration when that is not earlier. File names therefore sort in creation order, and so does the list in `registry.gen.go`, even for migrations made within one second.
Every file these commands write for you to edit starts with the line `//summer:make <kind>`, such as `//summer:make model` or `//summer:make admin-controller`. Keep it: `registry.gen.go` lists only the files that carry it, or the older `// Code generated by summer make. DO NOT EDIT.` header, which earlier versions wrote and which is still recognised. `registry.gen.go` itself keeps that header, so it is the only file linters treat as generated.
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers, and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers and embeds the admin YAML (`controllers/*/*.yaml` and `models/*/*.yaml`, once they exist), and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists, and its `AdminFS` (`pact.AdminAssets`) returns `generatedAdminFS()`, an empty tree until the first `make:admin-controller`. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.
Each command prints `Created <path>` for every file it writes. A command refuses to overwrite an existing file or to declare a name the package already has.

View File

@@ -1,5 +1,5 @@
name: acme.blog::lang.posts.post
form: ~/plugins/acme/blog/models/posts/fields.yaml
form: ~/plugins/acme/blog/models/post/fields.yaml
modelClass: Post
defaultRedirect: acme/blog/posts

View File

@@ -1,4 +1,4 @@
list: ~/plugins/acme/blog/models/posts/columns.yaml
list: ~/plugins/acme/blog/models/post/columns.yaml
modelClass: Post
title: acme.blog::lang.posts.title
recordUrl: acme/blog/posts/update/:id

View File

@@ -62,7 +62,7 @@ func TestPostsControllerDeclaration(t *testing.T) {
if list.ModelClass != "Post" || list.DefaultSort == nil {
t.Errorf("list = %+v, want modelClass Post with a default sort", list)
}
for _, name := range []string{"controllers/posts/config_form.yaml", "controllers/posts/config_list.yaml", "models/posts/fields.yaml", "models/posts/columns.yaml"} {
for _, name := range []string{"controllers/posts/config_form.yaml", "controllers/posts/config_list.yaml", "models/post/fields.yaml", "models/post/columns.yaml"} {
if _, err := fs.Stat(fsys, name); err != nil {
t.Errorf("AdminFS is missing %s: %v", name, err)
}

View File

@@ -38,9 +38,6 @@ var langFS embed.FS
//go:embed views/mail
var mailFS embed.FS
//go:embed controllers/*/*.yaml models/*/*.yaml
var adminFS embed.FS
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
type Plugin struct {
app *backpack.App
@@ -79,9 +76,9 @@ func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(p.app), console.PublishCommand(p.withDB))
}
// AdminFS is the admin YAML the controllers read: controllers/posts and
// models/posts.
func (p *Plugin) AdminFS() fs.FS { return adminFS }
// AdminFS is the admin YAML the controllers read, controllers/posts and
// models/post, which registry.gen.go embeds.
func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
// Permissions replaces registerPermissions().
func (p *Plugin) Permissions() []pact.Permission {

View File

@@ -3,6 +3,9 @@
package blog
import (
"embed"
"io/fs"
"git.golem15.com/golem15/summercms/docs/examples/blog/controllers"
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
"git.golem15.com/golem15/summercms/docs/examples/blog/updates"
@@ -40,3 +43,16 @@ func generatedAdminControllers() []pact.AdminController {
controllers.PostsController(),
}
}
// generatedAdminTree holds the admin YAML of the plugin: the controller
// configuration and the model fields and columns. Go evaluates the go:embed
// globs at compile time, so YAML files added later in these directories are
// included; the next make: command rewrites the line when a glob starts or
// stops matching a file.
//
//go:embed controllers/*/*.yaml models/*/*.yaml
var generatedAdminTree embed.FS
// generatedAdminFS returns the admin YAML for pact.AdminAssets: an empty tree
// until the first admin controller is scaffolded.
func generatedAdminFS() fs.FS { return generatedAdminTree }

View File

@@ -39,7 +39,7 @@ func TestScaffoldLayout(t *testing.T) {
if _, err := build.MakeMigration(ctx, app, "acme.blog", "AddPublishedAt"); err != nil {
t.Fatalf("MakeMigration: %v", err)
}
if _, err := build.MakeAdminController(ctx, app, "acme.blog", "Posts"); err != nil {
if _, err := build.MakeAdminController(ctx, app, "acme.blog", "Posts", ""); err != nil {
t.Fatalf("MakeAdminController: %v", err)
}
if _, err := build.MakeCommand(ctx, app, "acme.blog", "Publish"); err != nil {

View File

@@ -108,7 +108,7 @@ plugins/acme/blog/
├── go.mod the plugin module, requiring the framework
├── plugin.go the Plugin type, its capabilities and init registration
├── routes.go the Routes method
├── registry.gen.go generated lists of models, migrations, commands, jobs and admin controllers
├── registry.gen.go generated lists of models, migrations, commands, jobs and admin controllers, and the embedded admin YAML
├── classes/ services and hooks
├── config/config.yaml default configuration
├── console/ console commands

View File

@@ -29,7 +29,7 @@ summer build
| `summer make:plugin acme.blog` | `plugins/acme/blog` as its own Go module: `plugin.go`, `routes.go`, `registry.gen.go`, `go.mod`, `config/config.yaml`, `lang/en/lang.yaml`, `views/mail/welcome.htm` and a `doc.go` in `classes`, `console`, `controllers`, `jobs`, `middleware`, `models` and `updates` |
| `summer make:model acme.blog Post` | `models/post.go` and `updates/<timestamp>_create_acme_blog_posts.go` |
| `summer make:migration acme.blog AddPublishedAt` | `updates/<timestamp>_add_published_at.go` |
| `summer make:admin-controller acme.blog Posts` | `controllers/posts.go`, `controllers/posts/config_form.yaml`, `controllers/posts/config_list.yaml`, `models/posts/fields.yaml` and `models/posts/columns.yaml` |
| `summer make:admin-controller acme.blog Posts` | `controllers/posts.go`, `controllers/posts/config_form.yaml`, `controllers/posts/config_list.yaml`, `models/post/fields.yaml` and `models/post/columns.yaml` |
| `summer make:command acme.blog Publish` | `console/publish.go` |
| `summer plugin:add plugins/acme/blog` | The plugin in `summer.yaml`, a `require` and a local `replace` in the application's `go.mod`, and the directory in `go.work` |
| `summer build` | `bin/acme`, the application binary with the plugin compiled in |
@@ -149,9 +149,6 @@ var langFS embed.FS
//go:embed views/mail
var mailFS embed.FS
//go:embed controllers/*/*.yaml models/*/*.yaml
var adminFS embed.FS
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
type Plugin struct {
app *backpack.App
@@ -190,9 +187,9 @@ func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(p.app), console.PublishCommand(p.withDB))
}
// AdminFS is the admin YAML the controllers read: controllers/posts and
// models/posts.
func (p *Plugin) AdminFS() fs.FS { return adminFS }
// AdminFS is the admin YAML the controllers read, controllers/posts and
// models/post, which registry.gen.go embeds.
func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
// Permissions replaces registerPermissions().
func (p *Plugin) Permissions() []pact.Permission {
@@ -234,7 +231,7 @@ func init() {
}
```
Each `var _ pact.HasX = (*Plugin)(nil)` line is a compile-time check that the plugin really implements a capability; the application finds the capabilities by type assertion. `Boot` keeps the application, because the route handler and the command reach the database through it. `Models`, `Migrations`, `Jobs` and `AdminControllers` return `generatedModels()` and the other accessors from `registry.gen.go`, which the `make:` commands rewrite each time they add a model, a migration, a command, a job or an admin controller. `AdminFS`, `Permissions` and `Navigation` were added by hand for the backend, and `Commands` for the console command.
Each `var _ pact.HasX = (*Plugin)(nil)` line is a compile-time check that the plugin really implements a capability; the application finds the capabilities by type assertion. `Boot` keeps the application, because the route handler and the command reach the database through it. `Models`, `Migrations`, `Jobs` and `AdminControllers` return `generatedModels()` and the other accessors from `registry.gen.go`, which the `make:` commands rewrite each time they add a model, a migration, a command, a job or an admin controller. `AdminFS` returns `generatedAdminFS()`, the admin YAML `registry.gen.go` embeds, and `Commands` returns `generatedCommands(p.app)`; both come with the scaffold. `Permissions` and `Navigation` were added by hand for the backend, and `Commands` appends the console command.
The plugin's defaults live in `config/config.yaml`, the equivalent of the plugin's `config/config.php`. They are merged under the plugin ID, so this value is read as `acme.blog.per_page`:
@@ -573,7 +570,7 @@ class Posts extends Controller
}
```
`summer make:admin-controller acme.blog Posts` writes `controllers/posts.go` and four YAML files. In SummerCMS the controller has no actions or views: the framework's generic admin API lists, shows, creates, updates and deletes records, and the admin SPA draws the screens from the YAML. The controller only says which model it serves, where its YAML is and which permission it needs:
`summer make:admin-controller acme.blog Posts` writes `controllers/posts.go` and four YAML files for the model `Post`, the singular of the controller name (`--model` names another one). Because `make:model` already wrote `models.Post`, the generated `NewRecord` returns it. In SummerCMS the controller has no actions or views: the framework's generic admin API lists, shows, creates, updates and deletes records, and the admin SPA draws the screens from the YAML. The controller only says which model it serves, where its YAML is and which permission it needs:
```go src=docs/examples/blog/controllers/posts.go
//summer:make admin-controller
@@ -615,7 +612,7 @@ func PostsController() pact.AdminController { return postsAdmin{} }
`RequiredPermissions` replaces `$requiredPermissions` and is checked before any schema or record is served. `NewRecord` gives the admin API the model to query, and its `Fillable` and `Rules` decide what a save may write. The YAML keeps its WinterCMS syntax; `modelClass` must equal `ModelName`:
```yaml src=docs/examples/blog/controllers/posts/config_list.yaml
list: ~/plugins/acme/blog/models/posts/columns.yaml
list: ~/plugins/acme/blog/models/post/columns.yaml
modelClass: Post
title: acme.blog::lang.posts.title
recordUrl: acme/blog/posts/update/:id
@@ -632,7 +629,7 @@ toolbar:
```yaml src=docs/examples/blog/controllers/posts/config_form.yaml
name: acme.blog::lang.posts.post
form: ~/plugins/acme/blog/models/posts/fields.yaml
form: ~/plugins/acme/blog/models/post/fields.yaml
modelClass: Post
defaultRedirect: acme/blog/posts
@@ -645,9 +642,9 @@ update:
redirectClose: acme/blog/posts
```
The form and list fields sit under `models/posts/`, the controller's name, where `make:admin-controller` puts them (WinterCMS keeps them under `models/post/`, the model's name):
The form and list fields sit under `models/post/`, the model's name, as in WinterCMS; `make:admin-controller` puts them there:
```yaml src=docs/examples/blog/models/posts/fields.yaml
```yaml src=docs/examples/blog/models/post/fields.yaml
fields:
title:
label: acme.blog::lang.posts.title_field
@@ -665,7 +662,7 @@ fields:
size: large
```
```yaml src=docs/examples/blog/models/posts/columns.yaml
```yaml src=docs/examples/blog/models/post/columns.yaml
columns:
title:
label: acme.blog::lang.posts.title_field
@@ -683,9 +680,9 @@ The WinterCMS form had a date picker for `published_at`. SummerCMS forms have no
The plugin embeds the YAML through `pact.AdminAssets` and declares the permission and the menu entry, as `registerPermissions` and `registerNavigation` did:
```go src=docs/examples/blog/plugin.go#Plugin.AdminFS
// AdminFS is the admin YAML the controllers read: controllers/posts and
// models/posts.
func (p *Plugin) AdminFS() fs.FS { return adminFS }
// AdminFS is the admin YAML the controllers read, controllers/posts and
// models/post, which registry.gen.go embeds.
func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
```
```go src=docs/examples/blog/plugin.go#Plugin.Permissions
@@ -822,8 +819,9 @@ See [Writing commands](../console/writing-commands.md) for arguments, flags, pro
The `make:` commands write files that compile, not a finished plugin. These are the steps this walkthrough had to do by hand, and the scaffolder behaviour behind them:
- **The `//summer:make` line stays.** Files the `make:` commands write for you to edit start with `//summer:make <kind>`, such as `//summer:make model`. Keep the line: the commands rebuild `registry.gen.go` from the files that carry it (or the older `// Code generated by summer make. DO NOT EDIT.` header), so a model, migration, command, job or admin controller whose line you delete disappears from the accessors the next time you run a `make:` command. Only `registry.gen.go` itself is marked as generated, so linters check the files you edit.
- **The admin controller names the model after itself.** `make:admin-controller acme.blog Posts` returns `Posts` from `ModelName` and writes `modelClass: Posts`, and it puts `fields.yaml` and `columns.yaml` under `models/posts/`. Change `ModelName` and both `modelClass` values to the model, `Post`; the YAML can stay where it is, as in this example.
- **The admin controller needs more than the scaffolder writes.** The generated controller implements only `pact.AdminController`. Add `NewRecord` (`pact.AdminRecordSource`) so the admin API has a model to query, and `RequiredPermissions` (`pact.AdminPermissioned`): without it, any signed-in administrator can open the controller. The model needs `Fillable` and `Rules` before the admin API can save it. The plugin needs `AdminFS` (`pact.AdminAssets`) to embed the YAML; without it the application refuses to start once the admin is enabled.
- **Declare the admin permission.** The generated controller requires `acme.blog.access_posts`; declare it in the plugin's `Permissions()` (`pact.HasPermissions`), or the admin API refuses to start with an unknown-permission error.
- **Give the model `Fillable` and `Rules`.** The admin API saves only the fields `Fillable` lists and validates them with `Rules`; until the model has them, a save writes nothing.
- **Fill in `fields.yaml` and `columns.yaml`.** The scaffold lists only `id`; add the model's fields and columns. Run `make:model` before `make:admin-controller`, or `NewRecord` returns `nil` until you return the model yourself.
- **A command with a narrower dependency is added by hand.** The function `make:command` writes receives the `*backpack.App` the plugin kept from `Boot`, and the generated accessor lists such functions. A command that takes something else, such as the `withDB` of `blog:publish`, is left out of the accessor; return it from `Commands` yourself.
## Checklist