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

@@ -270,16 +270,24 @@ func makeAdminControllerCommand() bonfire.Command {
Name: "make:admin-controller", Name: "make:admin-controller",
Description: "Generate an admin controller with fields.yaml and columns.yaml", Description: "Generate an admin controller with fields.yaml and columns.yaml",
Long: `Writes controllers/<name>.go, its config_form.yaml and config_list.yaml, and Long: `Writes controllers/<name>.go, its config_form.yaml and config_list.yaml, and
starter fields.yaml and columns.yaml under models/<name>/, in the plugin with starter fields.yaml and columns.yaml under models/<model>/, in the plugin with
this ID or the plugin whose directory you are in. The controller requires the this ID or the plugin whose directory you are in. The model is the singular of
permission <plugin>.access_<name>, which you declare in the plugin's the name (Posts edits Post) unless --model names it. When models/ already has
Permissions(), and its NewRecord returns nil until you return your model, so that model, NewRecord returns it; otherwise NewRecord returns nil until you run
the screens stay closed until you finish it.`, make:model and return it, so the screens stay closed until you finish it. The
list offers create and delete. The controller requires the permission
<plugin>.access_<name>, which you declare in the plugin's Permissions(). A
scaffolded plugin serves the YAML through AdminFS from registry.gen.go.`,
Examples: []bonfire.Example{ Examples: []bonfire.Example{
{Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller in acme.blog"}, {Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller for the Post model in acme.blog"},
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/acme/blog"}, {Command: "make:admin-controller Posts", Description: "The same from inside plugins/acme/blog"},
{Command: "make:admin-controller acme.blog People --model Person", Description: "A People controller for the Person model"},
}, },
Args: makeArtifactArgs(), Args: makeArtifactArgs(),
Flags: []bonfire.Flag{{
Name: "model",
Description: "The model the controller edits (default: the singular of the name)",
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd() dir, err := os.Getwd()
if err != nil { if err != nil {
@@ -289,7 +297,8 @@ the screens stay closed until you finish it.`,
if err != nil { if err != nil {
return err return err
} }
result, err := build.MakeAdminController(ctx, dir, id, name) model, _ := in.Flag("model")
result, err := build.MakeAdminController(ctx, dir, id, name, model)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -139,6 +139,11 @@ func TestMakeCommandsViaCLI(t *testing.T) {
t.Fatalf("make:job output missing reindex.go:\n%s", out) t.Fatalf("make:job output missing reindex.go:\n%s", out)
} }
out = runSummer("make:admin-controller", "golem15.demo", "People", "--model", "Person")
if !strings.Contains(out, filepath.Join("models", "person", "fields.yaml")) {
t.Fatalf("make:admin-controller --model output missing models/person:\n%s", out)
}
t.Chdir(pluginDir) t.Chdir(pluginDir)
out = runSummer("make:command", "Purge") out = runSummer("make:command", "Purge")
if !strings.Contains(out, filepath.Join("console", "purge.go")) { if !strings.Contains(out, filepath.Join("console", "purge.go")) {

View File

@@ -152,9 +152,10 @@ func (p *BlogPlugin) Settings() []pact.SettingsItem {
```sh ```sh
summer make:admin-controller acme.blog Posts 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 ## 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: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: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: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. 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. 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. 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 name: acme.blog::lang.posts.post
form: ~/plugins/acme/blog/models/posts/fields.yaml form: ~/plugins/acme/blog/models/post/fields.yaml
modelClass: Post modelClass: Post
defaultRedirect: acme/blog/posts 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 modelClass: Post
title: acme.blog::lang.posts.title title: acme.blog::lang.posts.title
recordUrl: acme/blog/posts/update/:id recordUrl: acme/blog/posts/update/:id

View File

@@ -62,7 +62,7 @@ func TestPostsControllerDeclaration(t *testing.T) {
if list.ModelClass != "Post" || list.DefaultSort == nil { if list.ModelClass != "Post" || list.DefaultSort == nil {
t.Errorf("list = %+v, want modelClass Post with a default sort", list) 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 { if _, err := fs.Stat(fsys, name); err != nil {
t.Errorf("AdminFS is missing %s: %v", name, err) t.Errorf("AdminFS is missing %s: %v", name, err)
} }

View File

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

View File

@@ -3,6 +3,9 @@
package blog package blog
import ( import (
"embed"
"io/fs"
"git.golem15.com/golem15/summercms/docs/examples/blog/controllers" "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/models"
"git.golem15.com/golem15/summercms/docs/examples/blog/updates" "git.golem15.com/golem15/summercms/docs/examples/blog/updates"
@@ -40,3 +43,16 @@ func generatedAdminControllers() []pact.AdminController {
controllers.PostsController(), 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 { if _, err := build.MakeMigration(ctx, app, "acme.blog", "AddPublishedAt"); err != nil {
t.Fatalf("MakeMigration: %v", err) 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) t.Fatalf("MakeAdminController: %v", err)
} }
if _, err := build.MakeCommand(ctx, app, "acme.blog", "Publish"); err != nil { 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 ├── go.mod the plugin module, requiring the framework
├── plugin.go the Plugin type, its capabilities and init registration ├── plugin.go the Plugin type, its capabilities and init registration
├── routes.go the Routes method ├── 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 ├── classes/ services and hooks
├── config/config.yaml default configuration ├── config/config.yaml default configuration
├── console/ console commands ├── 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: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: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: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 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 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 | | `summer build` | `bin/acme`, the application binary with the plugin compiled in |
@@ -149,9 +149,6 @@ var langFS embed.FS
//go:embed views/mail //go:embed views/mail
var mailFS embed.FS 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. // Plugin is the acme.blog plugin, the Go form of Plugin.php.
type Plugin struct { type Plugin struct {
app *backpack.App app *backpack.App
@@ -190,9 +187,9 @@ func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(p.app), console.PublishCommand(p.withDB)) return append(generatedCommands(p.app), console.PublishCommand(p.withDB))
} }
// AdminFS is the admin YAML the controllers read: controllers/posts and // AdminFS is the admin YAML the controllers read, controllers/posts and
// models/posts. // models/post, which registry.gen.go embeds.
func (p *Plugin) AdminFS() fs.FS { return adminFS } func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
// Permissions replaces registerPermissions(). // Permissions replaces registerPermissions().
func (p *Plugin) Permissions() []pact.Permission { 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`: 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 ```go src=docs/examples/blog/controllers/posts.go
//summer:make admin-controller //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`: `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 ```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 modelClass: Post
title: acme.blog::lang.posts.title title: acme.blog::lang.posts.title
recordUrl: acme/blog/posts/update/:id recordUrl: acme/blog/posts/update/:id
@@ -632,7 +629,7 @@ toolbar:
```yaml src=docs/examples/blog/controllers/posts/config_form.yaml ```yaml src=docs/examples/blog/controllers/posts/config_form.yaml
name: acme.blog::lang.posts.post name: acme.blog::lang.posts.post
form: ~/plugins/acme/blog/models/posts/fields.yaml form: ~/plugins/acme/blog/models/post/fields.yaml
modelClass: Post modelClass: Post
defaultRedirect: acme/blog/posts defaultRedirect: acme/blog/posts
@@ -645,9 +642,9 @@ update:
redirectClose: acme/blog/posts 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: fields:
title: title:
label: acme.blog::lang.posts.title_field label: acme.blog::lang.posts.title_field
@@ -665,7 +662,7 @@ fields:
size: large size: large
``` ```
```yaml src=docs/examples/blog/models/posts/columns.yaml ```yaml src=docs/examples/blog/models/post/columns.yaml
columns: columns:
title: title:
label: acme.blog::lang.posts.title_field 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: 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 ```go src=docs/examples/blog/plugin.go#Plugin.AdminFS
// AdminFS is the admin YAML the controllers read: controllers/posts and // AdminFS is the admin YAML the controllers read, controllers/posts and
// models/posts. // models/post, which registry.gen.go embeds.
func (p *Plugin) AdminFS() fs.FS { return adminFS } func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
``` ```
```go src=docs/examples/blog/plugin.go#Plugin.Permissions ```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 `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 `//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. - **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.
- **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. - **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. - **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 ## Checklist

View File

@@ -3,6 +3,8 @@ package build
import ( import (
"context" "context"
"fmt" "fmt"
"go/ast"
"go/parser"
"go/token" "go/token"
"os" "os"
"path/filepath" "path/filepath"
@@ -38,6 +40,11 @@ type artifactData struct {
CommandName string CommandName string
Description string Description string
Permission string Permission string
// ModelIdent and ModelSnake name the model an admin controller edits;
// ModelImport is the plugin's models package when ModelIdent exists in it.
ModelIdent string
ModelSnake string
ModelImport string
} }
var migrationNow = func() time.Time { return time.Now().UTC() } var migrationNow = func() time.Time { return time.Now().UTC() }
@@ -260,7 +267,11 @@ func MakeJob(ctx context.Context, startDir, pluginID, name string) (ArtifactResu
} }
// MakeAdminController writes a pact.AdminController and Winter-shaped YAML. // MakeAdminController writes a pact.AdminController and Winter-shaped YAML.
func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (ArtifactResult, error) { // model names the record type the controller edits; empty means the singular
// of name (Posts gives Post). The form and list YAML go under
// models/<model snake>/, as in WinterCMS, and NewRecord returns the model
// when models/ already declares it.
func MakeAdminController(ctx context.Context, startDir, pluginID, name, model string) (ArtifactResult, error) {
plugin, err := resolvePlugin(startDir, pluginID) plugin, err := resolvePlugin(startDir, pluginID)
if err != nil { if err != nil {
return ArtifactResult{}, err return ArtifactResult{}, err
@@ -268,11 +279,18 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
if err := validateExportedIdent(name); err != nil { if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, identHint(fmt.Errorf("build: admin-controller: %w", err)) return ArtifactResult{}, identHint(fmt.Errorf("build: admin-controller: %w", err))
} }
if model == "" {
model = singularize(name)
}
if err := validateExportedIdent(model); err != nil {
return ArtifactResult{}, identHint(fmt.Errorf("build: admin-controller model: %w", err))
}
fn := name + "Controller" fn := name + "Controller"
if err := rejectDuplicateIdent(plugin.Dir, "controllers", fn); err != nil { if err := rejectDuplicateIdent(plugin.Dir, "controllers", fn); err != nil {
return ArtifactResult{}, err return ArtifactResult{}, err
} }
snake := toSnake(name) snake := toSnake(name)
modelSnake := toSnake(model)
rel := filepath.Join("controllers", snake+".go") rel := filepath.Join("controllers", snake+".go")
if err := rejectDuplicateFile(plugin.Dir, rel); err != nil { if err := rejectDuplicateFile(plugin.Dir, rel); err != nil {
return ArtifactResult{}, err return ArtifactResult{}, err
@@ -280,13 +298,17 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
configDir := filepath.ToSlash(filepath.Join("controllers", snake)) configDir := filepath.ToSlash(filepath.Join("controllers", snake))
formRel := filepath.Join("controllers", snake, "config_form.yaml") formRel := filepath.Join("controllers", snake, "config_form.yaml")
listRel := filepath.Join("controllers", snake, "config_list.yaml") listRel := filepath.Join("controllers", snake, "config_list.yaml")
fieldsRel := filepath.Join("models", snake, "fields.yaml") fieldsRel := filepath.Join("models", modelSnake, "fields.yaml")
columnsRel := filepath.Join("models", snake, "columns.yaml") columnsRel := filepath.Join("models", modelSnake, "columns.yaml")
for _, yamlRel := range []string{formRel, listRel, fieldsRel, columnsRel} { for _, yamlRel := range []string{formRel, listRel, fieldsRel, columnsRel} {
if err := rejectDuplicateFile(plugin.Dir, yamlRel); err != nil { if err := rejectDuplicateFile(plugin.Dir, yamlRel); err != nil {
return ArtifactResult{}, err return ArtifactResult{}, err
} }
} }
hasModel, err := declaresStruct(filepath.Join(plugin.Dir, "models"), model)
if err != nil {
return ArtifactResult{}, err
}
data := artifactData{ data := artifactData{
Ident: name, Ident: name,
Func: fn, Func: fn,
@@ -296,6 +318,11 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
PluginPath: strings.ReplaceAll(plugin.ID, ".", "/"), PluginPath: strings.ReplaceAll(plugin.ID, ".", "/"),
Snake: snake, Snake: snake,
Permission: plugin.ID + ".access_" + snake, Permission: plugin.ID + ".access_" + snake,
ModelIdent: model,
ModelSnake: modelSnake,
}
if hasModel {
data.ModelImport = plugin.Module + "/models"
} }
src, err := renderStub("admin_controller.go", data) src, err := renderStub("admin_controller.go", data)
if err != nil { if err != nil {
@@ -351,7 +378,87 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
return ArtifactResult{}, err return ArtifactResult{}, err
} }
rollback = false rollback = false
return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: plugin.Hint}, nil hint := plugin.Hint
if !hasModel {
hint = joinHints(hint, "note: models."+model+" does not exist yet, so NewRecord returns nil; run summer make:model "+plugin.ID+" "+model+
", then return &models."+model+"{} from NewRecord in "+filepath.ToSlash(rel))
}
declared, err := declaresMethod(plugin.Dir, "AdminFS")
if err != nil {
return ArtifactResult{}, err
}
if !declared {
hint = joinHints(hint, "note: the plugin declares no AdminFS method; implement pact.AdminAssets with func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }, "+
"or the application refuses to start once the admin is enabled")
}
return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: hint}, nil
}
// declaresStruct reports whether a non-test .go file in dir declares the
// exported struct type ident. Every file counts, marked or not.
func declaresStruct(dir, ident string) (bool, error) {
found := false
err := eachGoFile(dir, func(file *ast.File) {
if file.Name == nil || file.Name.Name != "models" {
return
}
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.TYPE {
continue
}
for _, spec := range gd.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok || ts.Name == nil || ts.Name.Name != ident {
continue
}
if _, ok := ts.Type.(*ast.StructType); ok {
found = true
}
}
}
})
return found, err
}
// declaresMethod reports whether a non-test .go file in dir declares a
// method named name.
func declaresMethod(dir, name string) (bool, error) {
found := false
err := eachGoFile(dir, func(file *ast.File) {
for _, decl := range file.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv != nil && fn.Name != nil && fn.Name.Name == name {
found = true
}
}
})
return found, err
}
// eachGoFile parses every non-test .go file directly in dir; a missing dir
// has none.
func eachGoFile(dir string, visit func(*ast.File)) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("build: read %s: %w", dir, err)
}
fset := token.NewFileSet()
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
path := filepath.Join(dir, name)
file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if err != nil {
return fmt.Errorf("build: parse %s: %w", path, err)
}
visit(file)
}
return nil
} }
// pluginIDHint explains an invalid plugin ID. // pluginIDHint explains an invalid plugin ID.
@@ -389,9 +496,10 @@ func unexportedIdent(name string) string {
} }
type pluginRef struct { type pluginRef struct {
Dir string Dir string
ID string ID string
Hint string Module string
Hint string
} }
func resolvePlugin(startDir, id string) (pluginRef, error) { func resolvePlugin(startDir, id string) (pluginRef, error) {
@@ -430,9 +538,10 @@ func resolvePlugin(startDir, id string) (pluginRef, error) {
return pluginRef{}, fmt.Errorf("build: plugin module: %w", err) return pluginRef{}, fmt.Errorf("build: plugin module: %w", err)
} }
return pluginRef{ return pluginRef{
Dir: pluginDir, Dir: pluginDir,
ID: id, ID: id,
Hint: accessorHint(filepath.Join(pluginDir, "plugin.go")), Module: modPath,
Hint: accessorHint(filepath.Join(pluginDir, "plugin.go")),
}, nil }, nil
} }
@@ -714,6 +823,44 @@ func exportedPlural(ident string) string {
return snakeToExported(pluralSnake(ident)) return snakeToExported(pluralSnake(ident))
} }
// singularize inverts pluralize on the last word of an exported identifier
// (BlogPosts gives BlogPost): ies becomes y; es is dropped after ss, us (but
// not ous or aus: Houses gives House), sis, x, ch, sh and zz, and quizzes
// gives quiz; a word ending in ss, us or is stays; otherwise a trailing s is
// dropped. English has more exceptions than these rules, so the admin
// controller scaffold takes an explicit model name too.
func singularize(word string) string {
lower := strings.ToLower(word)
has := func(suffixes ...string) bool {
for _, suffix := range suffixes {
if strings.HasSuffix(lower, suffix) {
return true
}
}
return false
}
switch {
case len(word) > 3 && has("ies"):
y := "y"
if word[len(word)-1] == 'S' {
y = "Y"
}
return word[:len(word)-3] + y
case has("izzes"):
return word[:len(word)-3]
case has("ouses", "auses"):
return word[:len(word)-1]
case has("sses", "uses", "sises", "xes", "ches", "shes", "zzes"):
return word[:len(word)-2]
case has("ss", "us", "is"):
return word
case len(word) > 1 && has("s"):
return word[:len(word)-1]
default:
return word
}
}
func pluralize(word string) string { func pluralize(word string) string {
switch { switch {
case word == "": case word == "":

View File

@@ -491,6 +491,8 @@ func TestScaffoldPluginSmoke(t *testing.T) {
"generatedMigrations()", "generatedMigrations()",
"generatedCommands(p.app)", "generatedCommands(p.app)",
"p.app = app", "p.app = app",
"_ pact.AdminAssets = (*Plugin)(nil)",
"func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }",
"generatedJobs()", "generatedJobs()",
"generatedAdminControllers()", "generatedAdminControllers()",
"//go:embed config", "//go:embed config",
@@ -505,6 +507,8 @@ func TestScaffoldPluginSmoke(t *testing.T) {
"func generatedModels()", "func generatedModels()",
"func generatedMigrations()", "func generatedMigrations()",
"func generatedCommands(app ...*backpack.App) []bonfire.Command", "func generatedCommands(app ...*backpack.App) []bonfire.Command",
"var generatedAdminTree embed.FS",
"func generatedAdminFS() fs.FS",
"func generatedJobs()", "func generatedJobs()",
"func generatedAdminControllers()", "func generatedAdminControllers()",
} { } {
@@ -839,7 +843,7 @@ func TestScaffoldAllArtifacts(t *testing.T) {
t.Fatal("job stub imports River") t.Fatal("job stub imports River")
} }
admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums") admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -847,8 +851,8 @@ func TestScaffoldAllArtifacts(t *testing.T) {
filepath.Join(pluginDir, "controllers", "albums.go"), filepath.Join(pluginDir, "controllers", "albums.go"),
filepath.Join(pluginDir, "controllers", "albums", "config_form.yaml"), filepath.Join(pluginDir, "controllers", "albums", "config_form.yaml"),
filepath.Join(pluginDir, "controllers", "albums", "config_list.yaml"), filepath.Join(pluginDir, "controllers", "albums", "config_list.yaml"),
filepath.Join(pluginDir, "models", "albums", "fields.yaml"), filepath.Join(pluginDir, "models", "album", "fields.yaml"),
filepath.Join(pluginDir, "models", "albums", "columns.yaml"), filepath.Join(pluginDir, "models", "album", "columns.yaml"),
} }
if len(admin.Files) != len(wantAdmin) { if len(admin.Files) != len(wantAdmin) {
t.Fatalf("make:admin-controller files = %v", admin.Files) t.Fatalf("make:admin-controller files = %v", admin.Files)
@@ -864,22 +868,27 @@ func TestScaffoldAllArtifacts(t *testing.T) {
} }
for _, want := range []string{ for _, want := range []string{
`ID() string { return "golem15.demo.albums" }`, `ID() string { return "golem15.demo.albums" }`,
`ModelName() string { return "Albums" }`, `ModelName() string { return "Album" }`,
`ConfigDir() string { return "controllers/albums" }`, `ConfigDir() string { return "controllers/albums" }`,
// WR-09: the scaffold is never open to every administrator and names // WR-09: the scaffold is never open to every administrator and names
// its record source, so completing it cannot silently expose the data. // its record source, so completing it cannot silently expose the data.
`pact.AdminPermissioned = albumsAdmin{}`, `pact.AdminPermissioned = albumsAdmin{}`,
`RequiredPermissions() []string`, `RequiredPermissions() []string`,
`return []string{"golem15.demo.access_albums"}`, `return []string{"golem15.demo.access_albums"}`,
`NewRecord() any { return nil }`, // make:model Album ran first, so NewRecord returns it.
`NewRecord() any { return &models.Album{} }`,
`"` + filepath.ToSlash(mustModule(t, pluginDir)) + `/models"`,
} { } {
if !bytes.Contains(adminSrc, []byte(want)) { if !bytes.Contains(adminSrc, []byte(want)) {
t.Fatalf("albums.go missing %s:\n%s", want, adminSrc) t.Fatalf("albums.go missing %s:\n%s", want, adminSrc)
} }
} }
if strings.Contains(admin.Hint, "NewRecord returns nil") {
t.Fatalf("hint names a missing model although models.Album exists: %s", admin.Hint)
}
for _, rel := range []string{ for _, rel := range []string{
filepath.Join("models", "albums", "fields.yaml"), filepath.Join("models", "album", "fields.yaml"),
filepath.Join("models", "albums", "columns.yaml"), filepath.Join("models", "album", "columns.yaml"),
} { } {
body, err := os.ReadFile(filepath.Join(pluginDir, rel)) body, err := os.ReadFile(filepath.Join(pluginDir, rel))
if err != nil { if err != nil {
@@ -1028,7 +1037,7 @@ func TestMakeArtifactRejectsMalformedNames(t *testing.T) {
if _, err := MakeJob(ctx, dir, "golem15.demo", name); err == nil { if _, err := MakeJob(ctx, dir, "golem15.demo", name); err == nil {
t.Fatalf("make:job %q: want error", name) t.Fatalf("make:job %q: want error", name)
} }
if _, err := MakeAdminController(ctx, dir, "golem15.demo", name); err == nil { if _, err := MakeAdminController(ctx, dir, "golem15.demo", name, ""); err == nil {
t.Fatalf("make:admin-controller %q: want error", name) t.Fatalf("make:admin-controller %q: want error", name)
} }
} }
@@ -1060,7 +1069,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
} }
t.Run("duplicate asset writes nothing", func(t *testing.T) { t.Run("duplicate asset writes nothing", func(t *testing.T) {
kept := filepath.Join(pluginDir, "models", "genres", "fields.yaml") kept := filepath.Join(pluginDir, "models", "genre", "fields.yaml")
if err := os.MkdirAll(filepath.Dir(kept), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(kept), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1068,7 +1077,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
if err := os.WriteFile(kept, []byte(original), 0o644); err != nil { if err := os.WriteFile(kept, []byte(original), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := MakeAdminController(ctx, dir, "golem15.demo", "Genres"); err == nil { if _, err := MakeAdminController(ctx, dir, "golem15.demo", "Genres", ""); err == nil {
t.Fatal("duplicate model asset want error") t.Fatal("duplicate model asset want error")
} }
body, err := os.ReadFile(kept) body, err := os.ReadFile(kept)
@@ -1079,7 +1088,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
filepath.Join("controllers", "genres.go"), filepath.Join("controllers", "genres.go"),
filepath.Join("controllers", "genres", "config_form.yaml"), filepath.Join("controllers", "genres", "config_form.yaml"),
filepath.Join("controllers", "genres", "config_list.yaml"), filepath.Join("controllers", "genres", "config_list.yaml"),
filepath.Join("models", "genres", "columns.yaml"), filepath.Join("models", "genre", "columns.yaml"),
} { } {
if _, err := os.Stat(filepath.Join(pluginDir, rel)); !os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(pluginDir, rel)); !os.IsNotExist(err) {
t.Fatalf("partial scaffold file %s err=%v", rel, err) t.Fatalf("partial scaffold file %s err=%v", rel, err)
@@ -1088,7 +1097,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
}) })
t.Run("winter files", func(t *testing.T) { t.Run("winter files", func(t *testing.T) {
admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums") admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1096,12 +1105,18 @@ func TestMakeAdminControllerLayout(t *testing.T) {
filepath.Join(pluginDir, "controllers", "albums.go"), filepath.Join(pluginDir, "controllers", "albums.go"),
filepath.Join(pluginDir, "controllers", "albums", "config_form.yaml"), filepath.Join(pluginDir, "controllers", "albums", "config_form.yaml"),
filepath.Join(pluginDir, "controllers", "albums", "config_list.yaml"), filepath.Join(pluginDir, "controllers", "albums", "config_list.yaml"),
filepath.Join(pluginDir, "models", "albums", "fields.yaml"), filepath.Join(pluginDir, "models", "album", "fields.yaml"),
filepath.Join(pluginDir, "models", "albums", "columns.yaml"), filepath.Join(pluginDir, "models", "album", "columns.yaml"),
} }
if strings.Join(admin.Files, "\n") != strings.Join(want, "\n") { if strings.Join(admin.Files, "\n") != strings.Join(want, "\n") {
t.Fatalf("files =\n%s\nwant\n%s", strings.Join(admin.Files, "\n"), strings.Join(want, "\n")) t.Fatalf("files =\n%s\nwant\n%s", strings.Join(admin.Files, "\n"), strings.Join(want, "\n"))
} }
if !strings.Contains(admin.Hint, "run summer make:model golem15.demo Album") || !strings.Contains(admin.Hint, "&models.Album{}") {
t.Fatalf("hint does not explain the missing model: %q", admin.Hint)
}
if strings.Contains(admin.Hint, "generatedAdminFS") {
t.Fatalf("scaffolded plugin got an AdminFS hint: %q", admin.Hint)
}
for _, rel := range []string{ for _, rel := range []string{
filepath.Join("controllers", "albums", "fields.yaml"), filepath.Join("controllers", "albums", "fields.yaml"),
filepath.Join("controllers", "albums", "columns.yaml"), filepath.Join("controllers", "albums", "columns.yaml"),
@@ -1116,8 +1131,10 @@ func TestMakeAdminControllerLayout(t *testing.T) {
} }
for _, text := range []string{ for _, text := range []string{
`ID() string { return "golem15.demo.albums" }`, `ID() string { return "golem15.demo.albums" }`,
`ModelName() string { return "Albums" }`, `ModelName() string { return "Album" }`,
`ConfigDir() string { return "controllers/albums" }`, `ConfigDir() string { return "controllers/albums" }`,
`NewRecord() any { return nil }`,
"TODO: run summer make:model for Album",
} { } {
if !bytes.Contains(goSrc, []byte(text)) { if !bytes.Contains(goSrc, []byte(text)) {
t.Fatalf("albums.go missing %s:\n%s", text, goSrc) t.Fatalf("albums.go missing %s:\n%s", text, goSrc)
@@ -1128,8 +1145,9 @@ func TestMakeAdminControllerLayout(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
for _, text := range []string{ for _, text := range []string{
"modelClass: Albums", "name: Album",
"form: ~/plugins/golem15/demo/models/albums/fields.yaml", "modelClass: Album",
"form: ~/plugins/golem15/demo/models/album/fields.yaml",
} { } {
if !bytes.Contains(formSrc, []byte(text)) { if !bytes.Contains(formSrc, []byte(text)) {
t.Fatalf("config_form.yaml missing %s:\n%s", text, formSrc) t.Fatalf("config_form.yaml missing %s:\n%s", text, formSrc)
@@ -1140,9 +1158,11 @@ func TestMakeAdminControllerLayout(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
for _, text := range []string{ for _, text := range []string{
"modelClass: Albums", "modelClass: Album",
"list: ~/plugins/golem15/demo/models/albums/columns.yaml", "title: Albums",
"buttons: [create]", "list: ~/plugins/golem15/demo/models/album/columns.yaml",
"showCheckboxes: true",
"buttons: [create, delete]",
"prompt: backend::lang.list.search_prompt", "prompt: backend::lang.list.search_prompt",
} { } {
if !bytes.Contains(listSrc, []byte(text)) { if !bytes.Contains(listSrc, []byte(text)) {
@@ -1174,7 +1194,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
if !bytes.Equal(reg1, reg2) { if !bytes.Equal(reg1, reg2) {
t.Fatal("repeated registry generation changed bytes") t.Fatal("repeated registry generation changed bytes")
} }
if _, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums"); err == nil { if _, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums", ""); err == nil {
t.Fatal("duplicate controller want error") t.Fatal("duplicate controller want error")
} }
reg3, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) reg3, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go"))
@@ -1197,7 +1217,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
type scaffoldAlbumsController struct{} type scaffoldAlbumsController struct{}
func (scaffoldAlbumsController) ID() string { return "golem15.demo.albums" } func (scaffoldAlbumsController) ID() string { return "golem15.demo.albums" }
func (scaffoldAlbumsController) ModelName() string { return "Albums" } func (scaffoldAlbumsController) ModelName() string { return "Album" }
func (scaffoldAlbumsController) ConfigDir() string { return "controllers/albums" } func (scaffoldAlbumsController) ConfigDir() string { return "controllers/albums" }
var _ pact.AdminController = scaffoldAlbumsController{} var _ pact.AdminController = scaffoldAlbumsController{}
@@ -1696,3 +1716,165 @@ func TestMakeCommandLegacyPluginGo(t *testing.T) {
t.Fatalf("legacy plugin.go vet: %v\n%s", err, out) t.Fatalf("legacy plugin.go vet: %v\n%s", err, out)
} }
} }
func mustModule(t *testing.T, pluginDir string) string {
t.Helper()
mod, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
if err != nil {
t.Fatal(err)
}
return mod
}
func TestSingularize(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"Posts", "Post"},
{"Albums", "Album"},
{"BlogPosts", "BlogPost"},
{"Categories", "Category"},
{"Boxes", "Box"},
{"Matches", "Match"},
{"Wishes", "Wish"},
{"Addresses", "Address"},
{"Genres", "Genre"},
{"Status", "Status"},
{"Statuses", "Status"},
{"Post", "Post"},
{"Houses", "House"},
{"Courses", "Course"},
{"Quizzes", "Quiz"},
{"Analysis", "Analysis"},
{"Days", "Day"},
} {
if got := singularize(tc.in); got != tc.want {
t.Errorf("singularize(%q) = %q, want %q", tc.in, got, tc.want)
}
}
for _, w := range []string{"post", "album", "category", "box", "match", "wish", "address", "genre", "status", "bus", "analysis", "day", "key", "buzz", "tax", "branch", "dish", "class", "house", "cause", "course", "index"} {
if got := singularize(pluralize(w)); got != w {
t.Errorf("singularize(pluralize(%q)) = singularize(%q) = %q", w, pluralize(w), got)
}
}
}
// TestMakeAdminControllerModel: --model overrides the singular, an invalid
// model writes nothing, and a plugin without AdminFS gets a hint.
func TestMakeAdminControllerModel(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
if err != nil {
t.Fatal(err)
}
if _, err := MakeAdminController(ctx, dir, "golem15.demo", "People", "person"); err == nil {
t.Fatal("lower-case model want error")
}
for _, rel := range []string{"controllers/people.go", "controllers/people", "models/person", "models/people"} {
if _, err := os.Stat(filepath.Join(pluginDir, filepath.FromSlash(rel))); !os.IsNotExist(err) {
t.Fatalf("invalid model left %s: %v", rel, err)
}
}
res, err := MakeAdminController(ctx, dir, "golem15.demo", "People", "Person")
if err != nil {
t.Fatal(err)
}
want := []string{
filepath.Join(pluginDir, "controllers", "people.go"),
filepath.Join(pluginDir, "controllers", "people", "config_form.yaml"),
filepath.Join(pluginDir, "controllers", "people", "config_list.yaml"),
filepath.Join(pluginDir, "models", "person", "fields.yaml"),
filepath.Join(pluginDir, "models", "person", "columns.yaml"),
}
if strings.Join(res.Files, "\n") != strings.Join(want, "\n") {
t.Fatalf("files =\n%s\nwant\n%s", strings.Join(res.Files, "\n"), strings.Join(want, "\n"))
}
for path, texts := range map[string][]string{
want[0]: {`ModelName() string { return "Person" }`, `ID() string { return "golem15.demo.people" }`, `return []string{"golem15.demo.access_people"}`},
want[1]: {"modelClass: Person", "form: ~/plugins/golem15/demo/models/person/fields.yaml", "defaultRedirect: golem15/demo/people"},
want[2]: {"modelClass: Person", "title: People", "list: ~/plugins/golem15/demo/models/person/columns.yaml"},
} {
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, text := range texts {
if !bytes.Contains(body, []byte(text)) {
t.Fatalf("%s missing %s:\n%s", path, text, body)
}
}
}
// The handwritten greeter plugin declares no AdminFS.
greeter, err := MakeAdminController(ctx, filepath.Join(dir, "plugins", "greeter"), "", "Waves", "")
if err != nil {
t.Fatal(err)
}
for _, text := range []string{"generatedAdminFS()", "pact.AdminAssets"} {
if !strings.Contains(greeter.Hint, text) {
t.Fatalf("greeter hint %q lacks %s", greeter.Hint, text)
}
}
}
// TestScaffoldAdminFSEndToEnd: a scaffolded plugin serves its admin YAML
// through AdminFS from registry.gen.go: an empty tree before the first
// controller, the controller and model YAML after it, and NewRecord returns
// the model make:model wrote.
func TestScaffoldAdminFSEndToEnd(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
if err != nil {
t.Fatal(err)
}
reg, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(reg, []byte("//go:embed")) || !bytes.Contains(reg, []byte("func generatedAdminFS() fs.FS")) {
t.Fatalf("fresh registry.gen.go:\n%s", reg)
}
probe := filepath.Join(pluginDir, "adminfs_probe_test.go")
runProbe := func(body string) {
t.Helper()
write(t, probe, "package demo\n\nimport (\n\t\"io/fs\"\n\t\"testing\"\n\n\t\"git.golem15.com/golem15/summercms/modules/pact\"\n)\n\nvar _ = fs.ReadFile\n\nfunc TestAdminFSProbe(t *testing.T) {\n\tvar p any = &Plugin{}\n\tassets, ok := p.(pact.AdminAssets)\n\tif !ok || assets.AdminFS() == nil {\n\t\tt.Fatal(\"no AdminFS\")\n\t}\n\tfsys := assets.AdminFS()\n"+body+"}\n")
if out, err := execGo(t, pluginDir, "test", "-count=1", "-run", "TestAdminFSProbe", ".").CombinedOutput(); err != nil {
t.Fatalf("probe: %v\n%s", err, out)
}
}
runProbe("\tentries, err := fs.ReadDir(fsys, \".\")\n\tif err != nil || len(entries) != 0 {\n\t\tt.Fatalf(\"empty tree: %v %v\", entries, err)\n\t}\n")
if _, err := MakeModel(ctx, dir, "golem15.demo", "Album", true); err != nil {
t.Fatal(err)
}
res, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums", "")
if err != nil {
t.Fatal(err)
}
if res.Hint != "" {
t.Fatalf("scaffolded plugin with the model printed a hint: %s", res.Hint)
}
goSrc, err := os.ReadFile(filepath.Join(pluginDir, "controllers", "albums.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(goSrc, []byte("NewRecord() any { return &models.Album{} }")) {
t.Fatalf("albums.go NewRecord:\n%s", goSrc)
}
reg, err = os.ReadFile(filepath.Join(pluginDir, "registry.gen.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(reg, []byte("//go:embed controllers/*/*.yaml models/*/*.yaml\nvar generatedAdminTree embed.FS")) {
t.Fatalf("registry.gen.go does not embed the admin YAML:\n%s", reg)
}
if out, err := execGo(t, pluginDir, "build", "./...").CombinedOutput(); err != nil {
t.Fatalf("plugin build: %v\n%s", err, out)
}
runProbe("\tfor _, name := range []string{\"controllers/albums/config_form.yaml\", \"controllers/albums/config_list.yaml\", \"models/album/fields.yaml\", \"models/album/columns.yaml\"} {\n\t\tif _, err := fs.ReadFile(fsys, name); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif _, err := fs.Stat(fsys, \"controllers/albums.go\"); err == nil {\n\t\tt.Fatal(\"Go source embedded\")\n\t}\n")
if err := os.Remove(probe); err != nil {
t.Fatal(err)
}
}

View File

@@ -52,8 +52,15 @@ type registryData struct {
AdminControllers []registryRef AdminControllers []registryRef
// AppCommands is set when at least one command takes the app. // AppCommands is set when at least one command takes the app.
AppCommands bool AppCommands bool
// AdminPatterns are the go:embed globs of the admin YAML, only those
// that match a file (go:embed rejects an empty pattern).
AdminPatterns []string
} }
// adminEmbedPatterns are the admin YAML globs registry.gen.go may embed:
// controller configuration and model fields and columns, never Go sources.
var adminEmbedPatterns = []string{"controllers/*/*.yaml", "models/*/*.yaml"}
func emptyRegistry(pkg, module string) registryData { func emptyRegistry(pkg, module string) registryData {
data := registryData{Package: pkg, Module: module} data := registryData{Package: pkg, Module: module}
data.prepare() data.prepare()
@@ -123,6 +130,15 @@ func refreshRegistry(pluginDir string) error {
sortRefs(data.Commands) sortRefs(data.Commands)
sortRefs(data.Jobs) sortRefs(data.Jobs)
sortRefs(data.AdminControllers) sortRefs(data.AdminControllers)
for _, pattern := range adminEmbedPatterns {
matches, err := filepath.Glob(filepath.Join(pluginDir, filepath.FromSlash(pattern)))
if err != nil {
return fmt.Errorf("build: glob %s: %w", pattern, err)
}
if len(matches) > 0 {
data.AdminPatterns = append(data.AdminPatterns, pattern)
}
}
return writeRegistry(pluginDir, data) return writeRegistry(pluginDir, data)
} }

View File

@@ -90,9 +90,14 @@ func {{.Func}}() pact.Job {
{{define "admin_controller.go"}}//summer:make admin-controller {{define "admin_controller.go"}}//summer:make admin-controller
package controllers package controllers
{{if .ModelImport}}
import (
{{printf "%q" .ModelImport}}
"git.golem15.com/golem15/summercms/modules/pact"
)
{{else}}
import "git.golem15.com/golem15/summercms/modules/pact" import "git.golem15.com/golem15/summercms/modules/pact"
{{end}}
var ( var (
_ pact.AdminController = {{.Worker}}{} _ pact.AdminController = {{.Worker}}{}
_ pact.AdminRecordSource = {{.Worker}}{} _ pact.AdminRecordSource = {{.Worker}}{}
@@ -102,13 +107,17 @@ var (
type {{.Worker}} struct{} type {{.Worker}} struct{}
func ({{.Worker}}) ID() string { return {{printf "%q" .Kind}} } func ({{.Worker}}) ID() string { return {{printf "%q" .Kind}} }
func ({{.Worker}}) ModelName() string { return {{printf "%q" .Ident}} } func ({{.Worker}}) ModelName() string { return {{printf "%q" .ModelIdent}} }
func ({{.Worker}}) ConfigDir() string { return {{printf "%q" .ConfigDir}} } func ({{.Worker}}) ConfigDir() string { return {{printf "%q" .ConfigDir}} }
// NewRecord returns the model the generic admin handlers query and fill. // NewRecord returns the model the generic admin handlers query and fill.
// TODO: return a pointer to the plugin's model, such as &models.{{.Ident}}{}. {{- if .ModelImport}}
func ({{.Worker}}) NewRecord() any { return &models.{{.ModelIdent}}{} }
{{- else}}
// TODO: run summer make:model for {{.ModelIdent}}, then return &models.{{.ModelIdent}}{}.
// Until then the list and every write answer 500, so nothing is exposed. // Until then the list and every write answer 500, so nothing is exposed.
func ({{.Worker}}) NewRecord() any { return nil } func ({{.Worker}}) NewRecord() any { return nil }
{{- end}}
// RequiredPermissions is checked before any schema or record is served. Keep // RequiredPermissions is checked before any schema or record is served. Keep
// it: a controller without one is open to every administrator. Declare the // it: a controller without one is open to every administrator. Declare the
@@ -122,9 +131,9 @@ func ({{.Worker}}) RequiredPermissions() []string {
func {{.Func}}() pact.AdminController { return {{.Worker}}{} } func {{.Func}}() pact.AdminController { return {{.Worker}}{} }
{{end}} {{end}}
{{define "config_form.yaml"}}name: {{.Ident}} {{define "config_form.yaml"}}name: {{.ModelIdent}}
form: ~/plugins/{{.PluginPath}}/models/{{.Snake}}/fields.yaml form: ~/plugins/{{.PluginPath}}/models/{{.ModelSnake}}/fields.yaml
modelClass: {{.Ident}} modelClass: {{.ModelIdent}}
defaultRedirect: {{.PluginPath}}/{{.Snake}} defaultRedirect: {{.PluginPath}}/{{.Snake}}
create: create:
@@ -136,12 +145,13 @@ update:
redirectClose: {{.PluginPath}}/{{.Snake}} redirectClose: {{.PluginPath}}/{{.Snake}}
{{end}} {{end}}
{{define "config_list.yaml"}}list: ~/plugins/{{.PluginPath}}/models/{{.Snake}}/columns.yaml {{define "config_list.yaml"}}list: ~/plugins/{{.PluginPath}}/models/{{.ModelSnake}}/columns.yaml
modelClass: {{.Ident}} modelClass: {{.ModelIdent}}
title: {{.Ident}} title: {{.Ident}}
recordsPerPage: 20 recordsPerPage: 20
showCheckboxes: true
toolbar: toolbar:
buttons: [create] buttons: [create, delete]
search: search:
prompt: backend::lang.list.search_prompt prompt: backend::lang.list.search_prompt
{{end}} {{end}}

View File

@@ -20,6 +20,7 @@ var (
_ pact.HasCommands = (*Plugin)(nil) _ pact.HasCommands = (*Plugin)(nil)
_ pact.HasJobs = (*Plugin)(nil) _ pact.HasJobs = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil) _ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.AdminAssets = (*Plugin)(nil)
) )
//go:embed config //go:embed config
@@ -65,6 +66,9 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers() return generatedAdminControllers()
} }
// AdminFS serves the admin YAML that registry.gen.go embeds.
func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
func init() { func init() {
party.Register(&Plugin{}) party.Register(&Plugin{})
} }

View File

@@ -3,6 +3,9 @@
package {{.Package}} package {{.Package}}
import ( import (
"embed"
"io/fs"
"git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/pact"
@@ -79,4 +82,19 @@ func generatedAdminControllers() []pact.AdminController {
return nil return nil
{{- end}} {{- end}}
} }
// 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.
{{- if .AdminPatterns}}
//
//go:embed{{range .AdminPatterns}} {{.}}{{end}}
{{- end}}
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 }
{{end}} {{end}}

View File

@@ -20,6 +20,7 @@ var (
_ pact.HasCommands = (*Plugin)(nil) _ pact.HasCommands = (*Plugin)(nil)
_ pact.HasJobs = (*Plugin)(nil) _ pact.HasJobs = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil) _ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.AdminAssets = (*Plugin)(nil)
) )
//go:embed config //go:embed config
@@ -65,6 +66,9 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers() return generatedAdminControllers()
} }
// AdminFS serves the admin YAML that registry.gen.go embeds.
func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
func init() { func init() {
party.Register(&Plugin{}) party.Register(&Plugin{})
} }

View File

@@ -192,7 +192,7 @@ Plugin CSS (declared through `pact.AdminClientAssets`) and any widget shadow DOM
## Usage ## Usage
A plugin exposes an admin controller and embeds its YAML. `summer make:admin-controller` scaffolds the controller type and its four YAML files: A plugin exposes an admin controller and embeds its YAML. `summer make:admin-controller` scaffolds the controller type and its four YAML files, names the model after the singular of the controller (or `--model`) and lists the controller in the plugin's generated `registry.gen.go`, which also embeds the YAML for a scaffolded plugin's `AdminFS`. Written by hand, the same pieces look like this:
```go ```go
package blog package blog