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:
@@ -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 == "":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user