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

@@ -3,6 +3,8 @@ package build
import (
"context"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
@@ -38,6 +40,11 @@ type artifactData struct {
CommandName string
Description 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() }
@@ -260,7 +267,11 @@ func MakeJob(ctx context.Context, startDir, pluginID, name string) (ArtifactResu
}
// 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)
if err != nil {
return ArtifactResult{}, err
@@ -268,11 +279,18 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
if err := validateExportedIdent(name); err != nil {
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"
if err := rejectDuplicateIdent(plugin.Dir, "controllers", fn); err != nil {
return ArtifactResult{}, err
}
snake := toSnake(name)
modelSnake := toSnake(model)
rel := filepath.Join("controllers", snake+".go")
if err := rejectDuplicateFile(plugin.Dir, rel); err != nil {
return ArtifactResult{}, err
@@ -280,13 +298,17 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
configDir := filepath.ToSlash(filepath.Join("controllers", snake))
formRel := filepath.Join("controllers", snake, "config_form.yaml")
listRel := filepath.Join("controllers", snake, "config_list.yaml")
fieldsRel := filepath.Join("models", snake, "fields.yaml")
columnsRel := filepath.Join("models", snake, "columns.yaml")
fieldsRel := filepath.Join("models", modelSnake, "fields.yaml")
columnsRel := filepath.Join("models", modelSnake, "columns.yaml")
for _, yamlRel := range []string{formRel, listRel, fieldsRel, columnsRel} {
if err := rejectDuplicateFile(plugin.Dir, yamlRel); err != nil {
return ArtifactResult{}, err
}
}
hasModel, err := declaresStruct(filepath.Join(plugin.Dir, "models"), model)
if err != nil {
return ArtifactResult{}, err
}
data := artifactData{
Ident: name,
Func: fn,
@@ -296,6 +318,11 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
PluginPath: strings.ReplaceAll(plugin.ID, ".", "/"),
Snake: snake,
Permission: plugin.ID + ".access_" + snake,
ModelIdent: model,
ModelSnake: modelSnake,
}
if hasModel {
data.ModelImport = plugin.Module + "/models"
}
src, err := renderStub("admin_controller.go", data)
if err != nil {
@@ -351,7 +378,87 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (
return ArtifactResult{}, err
}
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.
@@ -389,9 +496,10 @@ func unexportedIdent(name string) string {
}
type pluginRef struct {
Dir string
ID string
Hint string
Dir string
ID string
Module string
Hint string
}
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{
Dir: pluginDir,
ID: id,
Hint: accessorHint(filepath.Join(pluginDir, "plugin.go")),
Dir: pluginDir,
ID: id,
Module: modPath,
Hint: accessorHint(filepath.Join(pluginDir, "plugin.go")),
}, nil
}
@@ -714,6 +823,44 @@ func exportedPlural(ident string) string {
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 {
switch {
case word == "":

View File

@@ -491,6 +491,8 @@ func TestScaffoldPluginSmoke(t *testing.T) {
"generatedMigrations()",
"generatedCommands(p.app)",
"p.app = app",
"_ pact.AdminAssets = (*Plugin)(nil)",
"func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }",
"generatedJobs()",
"generatedAdminControllers()",
"//go:embed config",
@@ -505,6 +507,8 @@ func TestScaffoldPluginSmoke(t *testing.T) {
"func generatedModels()",
"func generatedMigrations()",
"func generatedCommands(app ...*backpack.App) []bonfire.Command",
"var generatedAdminTree embed.FS",
"func generatedAdminFS() fs.FS",
"func generatedJobs()",
"func generatedAdminControllers()",
} {
@@ -839,7 +843,7 @@ func TestScaffoldAllArtifacts(t *testing.T) {
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 {
t.Fatal(err)
}
@@ -847,8 +851,8 @@ func TestScaffoldAllArtifacts(t *testing.T) {
filepath.Join(pluginDir, "controllers", "albums.go"),
filepath.Join(pluginDir, "controllers", "albums", "config_form.yaml"),
filepath.Join(pluginDir, "controllers", "albums", "config_list.yaml"),
filepath.Join(pluginDir, "models", "albums", "fields.yaml"),
filepath.Join(pluginDir, "models", "albums", "columns.yaml"),
filepath.Join(pluginDir, "models", "album", "fields.yaml"),
filepath.Join(pluginDir, "models", "album", "columns.yaml"),
}
if len(admin.Files) != len(wantAdmin) {
t.Fatalf("make:admin-controller files = %v", admin.Files)
@@ -864,22 +868,27 @@ func TestScaffoldAllArtifacts(t *testing.T) {
}
for _, want := range []string{
`ID() string { return "golem15.demo.albums" }`,
`ModelName() string { return "Albums" }`,
`ModelName() string { return "Album" }`,
`ConfigDir() string { return "controllers/albums" }`,
// WR-09: the scaffold is never open to every administrator and names
// its record source, so completing it cannot silently expose the data.
`pact.AdminPermissioned = albumsAdmin{}`,
`RequiredPermissions() []string`,
`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)) {
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{
filepath.Join("models", "albums", "fields.yaml"),
filepath.Join("models", "albums", "columns.yaml"),
filepath.Join("models", "album", "fields.yaml"),
filepath.Join("models", "album", "columns.yaml"),
} {
body, err := os.ReadFile(filepath.Join(pluginDir, rel))
if err != nil {
@@ -1028,7 +1037,7 @@ func TestMakeArtifactRejectsMalformedNames(t *testing.T) {
if _, err := MakeJob(ctx, dir, "golem15.demo", name); err == nil {
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)
}
}
@@ -1060,7 +1069,7 @@ func TestMakeAdminControllerLayout(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 {
t.Fatal(err)
}
@@ -1068,7 +1077,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
if err := os.WriteFile(kept, []byte(original), 0o644); err != nil {
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")
}
body, err := os.ReadFile(kept)
@@ -1079,7 +1088,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
filepath.Join("controllers", "genres.go"),
filepath.Join("controllers", "genres", "config_form.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) {
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) {
admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums")
admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums", "")
if err != nil {
t.Fatal(err)
}
@@ -1096,12 +1105,18 @@ func TestMakeAdminControllerLayout(t *testing.T) {
filepath.Join(pluginDir, "controllers", "albums.go"),
filepath.Join(pluginDir, "controllers", "albums", "config_form.yaml"),
filepath.Join(pluginDir, "controllers", "albums", "config_list.yaml"),
filepath.Join(pluginDir, "models", "albums", "fields.yaml"),
filepath.Join(pluginDir, "models", "albums", "columns.yaml"),
filepath.Join(pluginDir, "models", "album", "fields.yaml"),
filepath.Join(pluginDir, "models", "album", "columns.yaml"),
}
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"))
}
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{
filepath.Join("controllers", "albums", "fields.yaml"),
filepath.Join("controllers", "albums", "columns.yaml"),
@@ -1116,8 +1131,10 @@ func TestMakeAdminControllerLayout(t *testing.T) {
}
for _, text := range []string{
`ID() string { return "golem15.demo.albums" }`,
`ModelName() string { return "Albums" }`,
`ModelName() string { return "Album" }`,
`ConfigDir() string { return "controllers/albums" }`,
`NewRecord() any { return nil }`,
"TODO: run summer make:model for Album",
} {
if !bytes.Contains(goSrc, []byte(text)) {
t.Fatalf("albums.go missing %s:\n%s", text, goSrc)
@@ -1128,8 +1145,9 @@ func TestMakeAdminControllerLayout(t *testing.T) {
t.Fatal(err)
}
for _, text := range []string{
"modelClass: Albums",
"form: ~/plugins/golem15/demo/models/albums/fields.yaml",
"name: Album",
"modelClass: Album",
"form: ~/plugins/golem15/demo/models/album/fields.yaml",
} {
if !bytes.Contains(formSrc, []byte(text)) {
t.Fatalf("config_form.yaml missing %s:\n%s", text, formSrc)
@@ -1140,9 +1158,11 @@ func TestMakeAdminControllerLayout(t *testing.T) {
t.Fatal(err)
}
for _, text := range []string{
"modelClass: Albums",
"list: ~/plugins/golem15/demo/models/albums/columns.yaml",
"buttons: [create]",
"modelClass: Album",
"title: Albums",
"list: ~/plugins/golem15/demo/models/album/columns.yaml",
"showCheckboxes: true",
"buttons: [create, delete]",
"prompt: backend::lang.list.search_prompt",
} {
if !bytes.Contains(listSrc, []byte(text)) {
@@ -1174,7 +1194,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
if !bytes.Equal(reg1, reg2) {
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")
}
reg3, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go"))
@@ -1197,7 +1217,7 @@ func TestMakeAdminControllerLayout(t *testing.T) {
type scaffoldAlbumsController struct{}
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" }
var _ pact.AdminController = scaffoldAlbumsController{}
@@ -1696,3 +1716,165 @@ func TestMakeCommandLegacyPluginGo(t *testing.T) {
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
// AppCommands is set when at least one command takes the app.
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 {
data := registryData{Package: pkg, Module: module}
data.prepare()
@@ -123,6 +130,15 @@ func refreshRegistry(pluginDir string) error {
sortRefs(data.Commands)
sortRefs(data.Jobs)
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)
}

View File

@@ -90,9 +90,14 @@ func {{.Func}}() pact.Job {
{{define "admin_controller.go"}}//summer:make admin-controller
package controllers
{{if .ModelImport}}
import (
{{printf "%q" .ModelImport}}
"git.golem15.com/golem15/summercms/modules/pact"
)
{{else}}
import "git.golem15.com/golem15/summercms/modules/pact"
{{end}}
var (
_ pact.AdminController = {{.Worker}}{}
_ pact.AdminRecordSource = {{.Worker}}{}
@@ -102,13 +107,17 @@ var (
type {{.Worker}} struct{}
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}} }
// 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.
func ({{.Worker}}) NewRecord() any { return nil }
{{- end}}
// RequiredPermissions is checked before any schema or record is served. Keep
// 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}}{} }
{{end}}
{{define "config_form.yaml"}}name: {{.Ident}}
form: ~/plugins/{{.PluginPath}}/models/{{.Snake}}/fields.yaml
modelClass: {{.Ident}}
{{define "config_form.yaml"}}name: {{.ModelIdent}}
form: ~/plugins/{{.PluginPath}}/models/{{.ModelSnake}}/fields.yaml
modelClass: {{.ModelIdent}}
defaultRedirect: {{.PluginPath}}/{{.Snake}}
create:
@@ -136,12 +145,13 @@ update:
redirectClose: {{.PluginPath}}/{{.Snake}}
{{end}}
{{define "config_list.yaml"}}list: ~/plugins/{{.PluginPath}}/models/{{.Snake}}/columns.yaml
modelClass: {{.Ident}}
{{define "config_list.yaml"}}list: ~/plugins/{{.PluginPath}}/models/{{.ModelSnake}}/columns.yaml
modelClass: {{.ModelIdent}}
title: {{.Ident}}
recordsPerPage: 20
showCheckboxes: true
toolbar:
buttons: [create]
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
{{end}}

View File

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

View File

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

View File

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